D:\a\cssh-rs\cssh-rs\cssh-rs-core\src\daemon\mod.rs
Line | Count | Source |
1 | | //! Daemon implementation |
2 | | |
3 | | #![deny(clippy::implicit_return)] |
4 | | #![allow(clippy::needless_return, clippy::doc_overindented_list_items)] |
5 | | #![warn(missing_docs)] |
6 | | |
7 | | use std::collections::HashMap; |
8 | | use std::{ |
9 | | io, |
10 | | sync::{Arc, Mutex}, |
11 | | time::Duration, |
12 | | }; |
13 | | use std::{thread, time}; |
14 | | |
15 | | use crate::get_console_window_handle; |
16 | | use crate::utils::config::{Cluster, DaemonConfig, EdgeBehavior}; |
17 | | use crate::utils::debug::StringRepr; |
18 | | use crate::utils::windows::{clear_screen, set_console_color, WindowsApi}; |
19 | | use cssh_rs_meta::PACKAGE_NAME; |
20 | | |
21 | | use crate::{ |
22 | | current_exe_path, spawn_console_process, |
23 | | utils::{ |
24 | | constants::PIPE_NAME, |
25 | | windows::{ |
26 | | arrange_console, get_console_input_buffer, read_keyboard_input, |
27 | | set_console_border_color, |
28 | | }, |
29 | | }, |
30 | | WindowsSettingsDefaultTerminalApplicationGuard, |
31 | | }; |
32 | | use bracoxide::explode; |
33 | | use cssh_rs_protocol::{ |
34 | | deserialization::deserialize_pid, |
35 | | serialization::{serialize_client_state, serialize_highlight, serialize_input_record_0}, |
36 | | ClientState, FRAMED_HIGHLIGHT_LENGTH, FRAMED_INPUT_RECORD_LENGTH, FRAMED_STATE_CHANGE_LENGTH, |
37 | | SERIALIZED_INPUT_RECORD_0_LENGTH, SERIALIZED_PID_LENGTH, TAG_HIGHLIGHT, TAG_INPUT_RECORD, |
38 | | TAG_KEEP_ALIVE, TAG_STATE_CHANGE, |
39 | | }; |
40 | | use log::{debug, error, warn}; |
41 | | use tokio::{ |
42 | | net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions}, |
43 | | sync::{ |
44 | | broadcast::{self, error::RecvError, Receiver, Sender}, |
45 | | watch, |
46 | | }, |
47 | | task::JoinHandle, |
48 | | }; |
49 | | use windows::Win32::System::Console::{ |
50 | | CONSOLE_CHARACTER_ATTRIBUTES, INPUT_RECORD_0, KEY_EVENT_RECORD, LEFT_ALT_PRESSED, |
51 | | LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SHIFT_PRESSED, |
52 | | }; |
53 | | |
54 | | use windows::Win32::UI::Input::KeyboardAndMouse::{ |
55 | | VIRTUAL_KEY, VK_A, VK_C, VK_D, VK_DOWN, VK_E, VK_ESCAPE, VK_H, VK_J, VK_K, VK_L, VK_LEFT, VK_N, |
56 | | VK_R, VK_RIGHT, VK_T, VK_UP, |
57 | | }; |
58 | | use windows::Win32::UI::WindowsAndMessaging::{SW_RESTORE, SW_SHOWMINIMIZED, SW_SHOWNOACTIVATE}; |
59 | | use windows::Win32::{ |
60 | | Foundation::{COLORREF, HANDLE, HWND, STILL_ACTIVE}, |
61 | | System::{Console::ENABLE_PROCESSED_INPUT, Threading::PROCESS_QUERY_INFORMATION}, |
62 | | }; |
63 | | |
64 | | use self::grid::{grid_dimensions, ClientGrid}; |
65 | | use self::workspace::WorkspaceArea; |
66 | | |
67 | | mod grid; |
68 | | mod workspace; |
69 | | |
70 | | /// The capacity of the broadcast channel used |
71 | | /// to send the input records read from the console input buffer |
72 | | /// to the named pipe servers connected to each client in parallel. |
73 | | const SENDER_CAPACITY: usize = 1024 * 1024; |
74 | | |
75 | | /// Bits in `KEY_EVENT_RECORD::dwControlKeyState` that represent |
76 | | /// "real" modifier keys (Ctrl / Alt / Shift) as opposed to lock |
77 | | /// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) or the |
78 | | /// `ENHANCED_KEY` flag. |
79 | | /// |
80 | | /// Control-mode key classification ANDs `dwControlKeyState` with |
81 | | /// this mask before matching; otherwise an enabled CapsLock or |
82 | | /// NumLock would make `dwControlKeyState` non-zero and silently |
83 | | /// skip every `(VK_*, 0)` arm. |
84 | | const MODIFIER_MASK: u32 = |
85 | | LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED | LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED | SHIFT_PRESSED; |
86 | | |
87 | | /// Top-level control-mode action a keystroke classifies into. |
88 | | /// |
89 | | /// Extracted from [`Daemon::handle_input_record`]'s dispatch match |
90 | | /// so the classification - including the [`MODIFIER_MASK`] step - |
91 | | /// can be regression tested without instantiating a full |
92 | | /// [`Daemon`]. |
93 | | #[derive(Debug, PartialEq, Eq)] |
94 | | enum ControlModeAction { |
95 | | /// `[r]` - rearrange every client window. |
96 | | Retile, |
97 | | /// `[e]` - open the enable/disable input submenu. |
98 | | OpenEnableDisableSubmenu, |
99 | | /// `[t]` - flip each client's [`ClientState`]. |
100 | | ToggleEnabled, |
101 | | /// `[n]` - force every client back to [`ClientState::Active`]. |
102 | | EnableAll, |
103 | | /// `[c]` - prompt for new hostnames and launch additional clients. |
104 | | CreateWindows, |
105 | | /// `[h]` - copy the active clients' hostnames to the clipboard. |
106 | | CopyHostnames, |
107 | | /// Any other key in the active control-mode prompt. |
108 | | NoOp, |
109 | | } |
110 | | |
111 | | /// Enable/disable-submenu action a keystroke classifies into. |
112 | | /// |
113 | | /// Extracted from [`Daemon::handle_enable_disable_submenu_key`]'s |
114 | | /// dispatch match for the same reason as [`ControlModeAction`]. |
115 | | #[derive(Debug, PartialEq, Eq)] |
116 | | enum EnableDisableSubmenuAction { |
117 | | /// `[e]` - force the targeted client(s) to [`ClientState::Active`]. |
118 | | Enable, |
119 | | /// `[d]` - force the targeted client(s) to [`ClientState::Disabled`]. |
120 | | Disable, |
121 | | /// `[t]` - flip the targeted client(s)' [`ClientState`]. |
122 | | Toggle, |
123 | | /// Arrow key or vim motion - move the submenu's selection cursor. |
124 | | Navigate(NavigationDirection), |
125 | | /// Any other key while the submenu is open. |
126 | | NoOp, |
127 | | } |
128 | | |
129 | | /// Direction of a navigation keystroke inside the enable/disable |
130 | | /// submenu. |
131 | | #[derive(Debug, PartialEq, Eq, Clone, Copy)] |
132 | | enum NavigationDirection { |
133 | | Up, |
134 | | Down, |
135 | | Left, |
136 | | Right, |
137 | | } |
138 | | |
139 | | /// Classifies a top-level control-mode keystroke. |
140 | | /// |
141 | | /// `control_key_state` is ANDed with [`MODIFIER_MASK`] so lock |
142 | | /// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) and the |
143 | | /// `ENHANCED_KEY` flag never bleed into the match - the |
144 | | /// `(VK_*, 0)` arms must still fire while any of those bits are |
145 | | /// set. Any "real" modifier bit (Ctrl / Alt / Shift) survives the |
146 | | /// mask and falls through to [`ControlModeAction::NoOp`]. |
147 | | /// |
148 | | /// # Arguments |
149 | | /// |
150 | | /// * `virtual_key` - The pressed key's [`VIRTUAL_KEY`]. |
151 | | /// * `control_key_state` - The raw `dwControlKeyState` field from |
152 | | /// the [`KEY_EVENT_RECORD`]. |
153 | | /// |
154 | | /// # Returns |
155 | | /// |
156 | | /// The [`ControlModeAction`] the dispatch should execute. |
157 | 78 | fn classify_control_mode_key( |
158 | 78 | virtual_key: VIRTUAL_KEY, |
159 | 78 | control_key_state: u32, |
160 | 78 | ) -> ControlModeAction { |
161 | 78 | return match (virtual_key, control_key_state & MODIFIER_MASK) { |
162 | 6 | (VK_R, 0) => ControlModeAction::Retile, |
163 | 6 | (VK_E, 0) => ControlModeAction::OpenEnableDisableSubmenu, |
164 | 6 | (VK_T, 0) => ControlModeAction::ToggleEnabled, |
165 | 6 | (VK_N, 0) => ControlModeAction::EnableAll, |
166 | 6 | (VK_C, 0) => ControlModeAction::CreateWindows, |
167 | 6 | (VK_H, 0) => ControlModeAction::CopyHostnames, |
168 | 42 | _ => ControlModeAction::NoOp, |
169 | | }; |
170 | 78 | } |
171 | | |
172 | | /// Classifies an enable/disable-submenu keystroke. |
173 | | /// |
174 | | /// See [`classify_control_mode_key`] for the [`MODIFIER_MASK`] |
175 | | /// rationale; the same lock-state / `ENHANCED_KEY` masking applies |
176 | | /// to the submenu so its `[e]`, `[d]`, `[t]` bindings keep working |
177 | | /// regardless of lock state. |
178 | | /// |
179 | | /// # Arguments |
180 | | /// |
181 | | /// * `virtual_key` - The pressed key's [`VIRTUAL_KEY`]. |
182 | | /// * `control_key_state` - The raw `dwControlKeyState` field from |
183 | | /// the [`KEY_EVENT_RECORD`]. |
184 | | /// |
185 | | /// # Returns |
186 | | /// |
187 | | /// The [`EnableDisableSubmenuAction`] the dispatch should execute. |
188 | 154 | fn classify_enable_disable_submenu_key( |
189 | 154 | virtual_key: VIRTUAL_KEY, |
190 | 154 | control_key_state: u32, |
191 | 154 | ) -> EnableDisableSubmenuAction { |
192 | 154 | return match (virtual_key, control_key_state & MODIFIER_MASK) { |
193 | 10 | (VK_E, 0) => EnableDisableSubmenuAction::Enable, |
194 | 7 | (VK_D, 0) => EnableDisableSubmenuAction::Disable, |
195 | 8 | (VK_T, 0) => EnableDisableSubmenuAction::Toggle, |
196 | 13 | (VK_UP, 0) | (VK_K, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Up), |
197 | 14 | (VK_DOWN, 0) | (VK_J, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Down), |
198 | 12 | (VK_LEFT, 0) | (VK_H, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Left), |
199 | | (VK_RIGHT, 0) | (VK_L, 0) => { |
200 | 12 | EnableDisableSubmenuAction::Navigate(NavigationDirection::Right) |
201 | | } |
202 | 78 | _ => EnableDisableSubmenuAction::NoOp, |
203 | | }; |
204 | 154 | } |
205 | | |
206 | | /// Representation of a client |
207 | | #[derive(Clone)] |
208 | | struct Client { |
209 | | /// Hostname the client is connect to (or supposed to connect to). |
210 | | hostname: String, |
211 | | /// Window handle to the clients console window. |
212 | | window_handle: HWND, |
213 | | /// Process handle to the client process. |
214 | | process_handle: HANDLE, |
215 | | /// Process id of the client process. |
216 | | /// |
217 | | /// Used by the pipe server task to correlate which client has connected |
218 | | /// to it, via a handshake over the named pipe. |
219 | | process_id: u32, |
220 | | /// Authoritative source for this client's [`ClientState`]. |
221 | | /// |
222 | | /// The daemon broadcasts new state values through the [`watch::Sender`]; |
223 | | /// the assigned pipe-server task subscribes upon successful PID |
224 | | /// correlation and forwards every change to the client over the named |
225 | | /// pipe. [`watch::Sender`] is itself [`Clone`], so cloning a [`Client`] |
226 | | /// produces another sender that drives the same channel. |
227 | | state_sender: watch::Sender<ClientState>, |
228 | | /// Authoritative source for this client's highlight flag, set while |
229 | | /// the client is the daemon's currently selected submenu client. |
230 | | /// Visual only; input gating uses [`Client::state_sender`]. |
231 | | highlight_sender: watch::Sender<bool>, |
232 | | /// Index passed to [`arrange_client_window`] when this client's |
233 | | /// on-screen position was last computed. Survives |
234 | | /// [`Clients::retain`] so the submenu navigation grid keeps |
235 | | /// matching the visible layout until the next retile. |
236 | | tile_index: usize, |
237 | | } |
238 | | |
239 | | unsafe impl Send for Client {} |
240 | | |
241 | | /// Collection of [`Client`]s maintaining insertion order and a PID-indexed |
242 | | /// lookup table. |
243 | | /// |
244 | | /// The ordered list preserves client window placement semantics, while the |
245 | | /// index enables O(1) lookup by process id - required by the pipe server task |
246 | | /// during PID correlation and future per-client pipe server control. |
247 | | struct Clients { |
248 | | /// Ordered list of clients; order matches launch order and is used for |
249 | | /// window arrangement and z-order synchronization. |
250 | | list: Vec<Client>, |
251 | | /// Maps a client's process id to its index in [`list`](Clients::list). |
252 | | pid_index: HashMap<u32, usize>, |
253 | | /// `number_of_consoles` value the current on-screen layout was |
254 | | /// computed with. Drives [`grid_dimensions`] for the submenu nav. |
255 | | /// Updated when the tiler positions windows |
256 | | /// ([`Clients::reset_tile_layout`]); preserved across |
257 | | /// [`Clients::retain`] so a closed-but-not-retiled window leaves |
258 | | /// a visible gap in the grid too. |
259 | | layout_n: usize, |
260 | | } |
261 | | |
262 | | impl Clients { |
263 | | /// Creates a new empty collection. |
264 | 27 | fn new() -> Self { |
265 | 27 | return Clients { |
266 | 27 | list: Vec::new(), |
267 | 27 | pid_index: HashMap::new(), |
268 | 27 | layout_n: 0, |
269 | 27 | }; |
270 | 27 | } |
271 | | |
272 | | /// Appends a client to the collection and records its position in the |
273 | | /// PID index. |
274 | | /// |
275 | | /// # Arguments |
276 | | /// |
277 | | /// * `client` - The [`Client`] to add. |
278 | | /// |
279 | | /// # Panics |
280 | | /// |
281 | | /// Panics if a client with the same process id is already present, as |
282 | | /// duplicate PIDs indicate broken daemon bookkeeping. |
283 | 59 | fn push(&mut self, mut client: Client) { |
284 | 59 | let index = self.list.len(); |
285 | 59 | assert!( |
286 | 59 | !self.pid_index.contains_key(&client.process_id), |
287 | | "Duplicate client PID {} - daemon bookkeeping broken", |
288 | | client.process_id, |
289 | | ); |
290 | | // Push assumes the new client occupies the next cell in a dense |
291 | | // layout - matching what the tiler does at initial launch and |
292 | | // right after `[c]reate`. `retain` leaves these values alone so |
293 | | // closed-but-not-retiled gaps stay visible to the navigation |
294 | | // grid. The next retile renumbers everything dense again. |
295 | 58 | client.tile_index = index; |
296 | 58 | self.pid_index.insert(client.process_id, index); |
297 | 58 | self.list.push(client); |
298 | 58 | self.layout_n = self.list.len(); |
299 | 58 | } |
300 | | |
301 | | /// Reassigns dense [`Client::tile_index`] values to `valid_pids` in |
302 | | /// the supplied order and snapshots the new [`Clients::layout_n`]. |
303 | | /// |
304 | | /// Called by [`Daemon::rearrange_client_windows`] right before it |
305 | | /// re-positions the actual windows on screen, so navigation reads |
306 | | /// the same layout the tiler just applied. |
307 | | /// |
308 | | /// Invariant: `valid_pids` must cover every PID currently tracked |
309 | | /// in `self.list`. Passing a strict subset (e.g. a liveness-filtered |
310 | | /// list) would shrink `layout_n` while leaving stale `tile_index >= |
311 | | /// layout_n` values on the excluded clients, breaking the |
312 | | /// [`ClientGrid`] built from this collection. Drop dead clients |
313 | | /// via [`Clients::retain`] before retiling. |
314 | | /// |
315 | | /// # Arguments |
316 | | /// |
317 | | /// * `valid_pids` - PIDs of the clients that will be tiled, in the |
318 | | /// order they will be passed to |
319 | | /// [`arrange_client_window`]. |
320 | 1 | fn reset_tile_layout(&mut self, valid_pids: &[u32]) { |
321 | 1 | debug_assert_eq!( |
322 | 1 | valid_pids.len(), |
323 | 1 | self.list.len(), |
324 | | "reset_tile_layout must receive every tracked client; \ |
325 | | call Clients::retain to drop dead entries first", |
326 | | ); |
327 | 4 | for (index, pid) in valid_pids1 .iter1 ().enumerate1 () { |
328 | 4 | if let Some(&list_index) = self.pid_index.get(pid) { |
329 | 4 | self.list[list_index].tile_index = index; |
330 | 4 | }0 |
331 | | } |
332 | 1 | self.layout_n = valid_pids.len(); |
333 | 1 | } |
334 | | |
335 | | /// Returns a reference to the client with the given process id, if any. |
336 | | /// |
337 | | /// # Arguments |
338 | | /// |
339 | | /// * `pid` - The process id of the client to look up. |
340 | | /// |
341 | | /// # Returns |
342 | | /// |
343 | | /// `Some(&Client)` if a client with the given PID exists, `None` otherwise. |
344 | 39 | fn get_by_pid(&self, pid: u32) -> Option<&Client> { |
345 | 39 | return self |
346 | 39 | .pid_index |
347 | 39 | .get(&pid) |
348 | 39 | .map(|&index| return &self.list[index]34 ); |
349 | 39 | } |
350 | | |
351 | | /// Retains only the clients for which the predicate returns `true`, |
352 | | /// rebuilding the PID index to reflect the new positions. |
353 | | /// |
354 | | /// # Arguments |
355 | | /// |
356 | | /// * `f` - Predicate applied to each [`Client`]; kept when it returns `true`. |
357 | 5 | fn retain<F: FnMut(&Client) -> bool>(&mut self, mut f: F) { |
358 | 17 | self.list5 .retain5 (|client| return f(client)); |
359 | 5 | self.pid_index.clear(); |
360 | 10 | for (index, client) in self.list.iter()5 .enumerate5 () { |
361 | 10 | self.pid_index.insert(client.process_id, index); |
362 | 10 | } |
363 | 5 | } |
364 | | } |
365 | | |
366 | | /// Allows treating a [`Clients`] collection as a `&[Client]`, so callers can |
367 | | /// use `&clients` where a slice is expected and get slice methods |
368 | | /// (`iter`, `len`, `is_empty`, ...) via deref coercion. |
369 | | impl std::ops::Deref for Clients { |
370 | | type Target = [Client]; |
371 | | |
372 | 46 | fn deref(&self) -> &[Client] { |
373 | 46 | return &self.list; |
374 | 46 | } |
375 | | } |
376 | | |
377 | | /// Consumes the collection and yields its clients in insertion order. |
378 | | /// |
379 | | /// Used when merging a freshly launched [`Clients`] batch into an existing |
380 | | /// collection while also spawning per-client pipe servers. |
381 | | impl IntoIterator for Clients { |
382 | | type Item = Client; |
383 | | type IntoIter = std::vec::IntoIter<Client>; |
384 | | |
385 | 0 | fn into_iter(self) -> Self::IntoIter { |
386 | 0 | return self.list.into_iter(); |
387 | 0 | } |
388 | | } |
389 | | |
390 | | /// Hacky wrapper around a window handle. |
391 | | /// |
392 | | /// As we cannot implement foreign traits for foreign structs |
393 | | /// we introduce this wrapper to implement [Send] for [HWND]. |
394 | | #[derive(Debug, Eq)] |
395 | | struct HWNDWrapper { |
396 | | hwdn: HWND, |
397 | | } |
398 | | |
399 | | unsafe impl Send for HWNDWrapper {} |
400 | | |
401 | | impl PartialEq for HWNDWrapper { |
402 | | /// Returns whether to `HWNDWrapper` instances are equal or not |
403 | | /// based on the [HWND] they wrap. |
404 | 2 | fn eq(&self, other: &Self) -> bool { |
405 | 2 | return self.hwdn == other.hwdn; |
406 | 2 | } |
407 | | } |
408 | | |
409 | | /// Returns a window handle to the current console window. |
410 | | /// |
411 | | /// The [HWND] is wrapped in a `HWNDWrapper` so that |
412 | | /// we can pass it inbetween threads. |
413 | 0 | fn get_console_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper { |
414 | 0 | return HWNDWrapper { |
415 | 0 | hwdn: api.get_console_window(), |
416 | 0 | }; |
417 | 0 | } |
418 | | |
419 | | /// Returns a window handle to the foreground window. |
420 | | /// |
421 | | /// The [HWND] is wrapped in a `HWNDWrapper` so that |
422 | | /// we can pass it inbetween threads. |
423 | 0 | fn get_foreground_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper { |
424 | 0 | return HWNDWrapper { |
425 | 0 | hwdn: api.get_foreground_window(), |
426 | 0 | }; |
427 | 0 | } |
428 | | |
429 | | /// Enum of all possible control mode states. |
430 | | #[derive(PartialEq, Debug)] |
431 | | enum ControlModeState { |
432 | | /// Controle mode is inactive. |
433 | | Inactive, |
434 | | /// One of the keys required for the control mode key combination |
435 | | /// is currently being pressed. |
436 | | Initiated, |
437 | | /// All required keys for the control mode key combination were pressed |
438 | | /// and control mode is now active. |
439 | | /// |
440 | | /// Active control mode prevents any input records from being sent to clients. |
441 | | Active, |
442 | | /// The user opened the `[e]nable/disable input` submenu from |
443 | | /// [`ControlModeState::Active`]; left only via `Esc`, which exits |
444 | | /// control mode entirely. `highlighted_pid` is the currently selected |
445 | | /// client (`None` when the cluster is empty); tracking by PID survives |
446 | | /// background-monitor `retain`s while the submenu is open. |
447 | | /// |
448 | | /// `anchor_col` is the upper-grid column carried across vertical |
449 | | /// moves so a Down + Up roundtrip across the partial-last-row |
450 | | /// boundary returns to the start cell. `None` while |
451 | | /// `highlighted_pid` is `None`. |
452 | | EnableDisableSubmenu { |
453 | | /// PID of the highlighted client, or `None` for an empty cluster. |
454 | | highlighted_pid: Option<u32>, |
455 | | /// Anchor upper-grid column carried across vertical moves. |
456 | | anchor_col: Option<i32>, |
457 | | }, |
458 | | } |
459 | | |
460 | | /// The daemon is responsible to launch a client for |
461 | | /// each host, positioning the client windows, forwarding |
462 | | /// input records to all clients and handling control mode. |
463 | | struct Daemon<'a> { |
464 | | /// A list of hostnames to connect to. |
465 | | hosts: Vec<String>, |
466 | | /// A username to use to connect to all clients. |
467 | | /// |
468 | | /// If it is empty the clients will use the SSH config to find an approriate |
469 | | /// username. |
470 | | username: Option<String>, |
471 | | /// Optional port used for all SSH connections. |
472 | | port: Option<u16>, |
473 | | /// The `DaemonConfig` that controls how the daemon console window looks like. |
474 | | config: &'a DaemonConfig, |
475 | | /// List of available cluster tags |
476 | | clusters: &'a [Cluster], |
477 | | /// The current control mode state. The submenu's selected client |
478 | | /// is carried inline on |
479 | | /// [`ControlModeState::EnableDisableSubmenu`] - tying its |
480 | | /// lifetime to the variant guarantees no stale highlight survives |
481 | | /// after `Esc`. |
482 | | control_mode_state: ControlModeState, |
483 | | /// If debug mode is enabled on the daemon it will also be enabled on all |
484 | | /// clients. |
485 | | debug: bool, |
486 | | } |
487 | | |
488 | | /// Compute the next submenu selection given a grid step. |
489 | | /// |
490 | | /// Re-anchors on the first surviving client when `current_pid` is no |
491 | | /// longer present (retained out while the submenu was open). |
492 | | /// |
493 | | /// # Arguments |
494 | | /// |
495 | | /// * `grid` - Spatial grid view over the currently tracked clients. |
496 | | /// * `current_pid` - PID currently highlighted, or `None`. |
497 | | /// * `anchor_col` - Anchor column carried from earlier moves. |
498 | | /// * `direction` - Direction the navigation keystroke encoded. |
499 | | /// * `edge` - Behavior when the move would leave the grid. |
500 | | /// |
501 | | /// # Returns |
502 | | /// |
503 | | /// `(new_pid, new_anchor_col)` to apply, or `(None, None)` for an empty |
504 | | /// cluster. |
505 | 33 | fn next_submenu_selection( |
506 | 33 | grid: &ClientGrid, |
507 | 33 | current_pid: Option<u32>, |
508 | 33 | anchor_col: Option<i32>, |
509 | 33 | direction: NavigationDirection, |
510 | 33 | edge: EdgeBehavior, |
511 | 33 | ) -> (Option<u32>, Option<i32>) { |
512 | 33 | if grid.is_empty() { |
513 | 8 | return (None, None); |
514 | 25 | } |
515 | 25 | let current_pid23 = match current_pid.and_then(|pid| return grid.cell(pid)) { |
516 | 23 | Some(cell) => cell.pid, |
517 | | None => { |
518 | 2 | let first = grid.top_left_pid(); |
519 | 2 | let first_anchor = first |
520 | 2 | .and_then(|pid| return grid.cell(pid)) |
521 | 2 | .map(|c| return grid.anchor_for(c)); |
522 | 2 | return (first, first_anchor); |
523 | | } |
524 | | }; |
525 | 23 | let anchor = anchor_col.unwrap_or_else(|| {0 |
526 | 0 | return grid |
527 | 0 | .cell(current_pid) |
528 | 0 | .map(|c| return grid.anchor_for(c)) |
529 | 0 | .unwrap_or(0); |
530 | 0 | }); |
531 | 23 | return match grid.step(current_pid, anchor, direction, edge) { |
532 | 23 | Some((pid, new_anchor)) => (Some(pid), Some(new_anchor)), |
533 | 0 | None => (Some(current_pid), Some(anchor)), |
534 | | }; |
535 | 33 | } |
536 | | |
537 | | /// Build a [`ClientGrid`] from `clients` and `workspace_area` using the |
538 | | /// same aspect-ratio expression the tiler uses. |
539 | | /// |
540 | | /// # Arguments |
541 | | /// |
542 | | /// * `clients` - Currently tracked clients in launch order. |
543 | | /// * `workspace_area` - Available workspace minus the daemon console. |
544 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon config. |
545 | | /// |
546 | | /// # Returns |
547 | | /// |
548 | | /// A populated [`ClientGrid`]. |
549 | 3 | fn build_client_grid( |
550 | 3 | clients: &Clients, |
551 | 3 | workspace_area: &workspace::WorkspaceArea, |
552 | 3 | aspect_ratio_adjustment: f64, |
553 | 3 | ) -> ClientGrid { |
554 | 3 | let aspect = workspace_aspect_ratio(workspace_area); |
555 | 3 | let layout_n = clients.layout_n as i32; |
556 | 3 | let (cols, rows) = grid_dimensions(layout_n, aspect, aspect_ratio_adjustment); |
557 | 3 | let cells: Vec<(u32, usize)> = clients |
558 | 3 | .iter() |
559 | 7 | .map3 (|c| return (c.process_id, c.tile_index)) |
560 | 3 | .collect(); |
561 | 3 | return ClientGrid::from_tiled_pids(&cells, layout_n, cols, rows); |
562 | 3 | } |
563 | | |
564 | | impl<'a> Daemon<'a> { |
565 | | /// Builds a minimal [`Daemon`] suitable for unit tests. |
566 | | /// |
567 | | /// Populates every field with defaults that do not touch the |
568 | | /// Windows API or the network. Tests pick the |
569 | | /// [`ControlModeState`] they need to exercise; everything else |
570 | | /// stays inert. |
571 | | #[cfg(test)] |
572 | 16 | fn for_test( |
573 | 16 | config: &'a DaemonConfig, |
574 | 16 | clusters: &'a [Cluster], |
575 | 16 | control_mode_state: ControlModeState, |
576 | 16 | ) -> Self { |
577 | 16 | return Self { |
578 | 16 | hosts: Vec::new(), |
579 | 16 | username: None, |
580 | 16 | port: None, |
581 | 16 | config, |
582 | 16 | clusters, |
583 | 16 | control_mode_state, |
584 | 16 | debug: false, |
585 | 16 | }; |
586 | 16 | } |
587 | | |
588 | | /// Launches all client windows and blocks on the main run loop. |
589 | | /// |
590 | | /// Sets up the daemon console by disabling processed input mode and applying |
591 | | /// the configured colors and dimensions. |
592 | | /// Once all client windows have successfully started the daemon console window |
593 | | /// is moved to the foreground and receives focus. |
594 | 0 | async fn launch<W: WindowsApi + Clone + 'static>(mut self, windows_api: &W) { |
595 | 0 | windows_api |
596 | 0 | .set_console_title(format!("{PACKAGE_NAME} daemon").as_str()) |
597 | 0 | .unwrap(); |
598 | 0 | set_console_color( |
599 | 0 | windows_api, |
600 | 0 | CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color), |
601 | | ); |
602 | 0 | set_console_border_color(windows_api, COLORREF(0x000000FF)); |
603 | | |
604 | 0 | toggle_processed_input_mode(windows_api); // Disable processed input mode |
605 | | |
606 | 0 | let workspace_area = workspace::get_workspace_area(windows_api, self.config.height); |
607 | | |
608 | 0 | self.arrange_daemon_console(windows_api, &workspace_area); |
609 | | |
610 | | // Looks like on windows 10 re-arranging the console resets the console output buffer |
611 | 0 | set_console_color( |
612 | 0 | windows_api, |
613 | 0 | CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color), |
614 | | ); |
615 | | |
616 | 0 | let mut clients = Arc::new(Mutex::new( |
617 | 0 | launch_clients( |
618 | 0 | windows_api, |
619 | 0 | self.hosts.to_vec(), |
620 | 0 | &self.username, |
621 | 0 | self.port, |
622 | 0 | self.debug, |
623 | 0 | &workspace_area, |
624 | 0 | self.config.aspect_ratio_adjustment, |
625 | 0 | 0, |
626 | 0 | ) |
627 | 0 | .await, |
628 | | )); |
629 | | |
630 | | // Now that all clients started, focus the daemon console again. |
631 | 0 | let daemon_console = windows_api.get_console_window(); |
632 | 0 | let _ = windows_api.bring_window_to_top(daemon_console, true); |
633 | | |
634 | 0 | self.print_instructions(windows_api); |
635 | 0 | self.run(windows_api, &mut clients, &workspace_area).await; |
636 | 0 | } |
637 | | |
638 | | /// The main run loop of the `daemon` subcommand. |
639 | | /// |
640 | | /// Opens a multi-producer, multi-consumer broadcasting channel used to |
641 | | /// send the read input records in parallel to the name pipe servers |
642 | | /// the clients are listening on. |
643 | | /// Spawns a background thread that waits for all clients to terminate |
644 | | /// and then stops the current process. |
645 | | /// Spawns a background thread that ensures the z-order of all client |
646 | | /// windows is in sync with the daemon window. |
647 | | /// I.e. if the daemon window is focussed, all clients should be moved to the foreground. |
648 | | /// |
649 | | /// The main loop consists of waiting for input records to read from the keyboard, |
650 | | /// sending them to all clients and handling control mode. |
651 | | /// |
652 | | /// # Arguments |
653 | | /// |
654 | | /// * `windows_api` - The Windows API implementation to use |
655 | | /// * `clients` - A thread safe mapping from the number |
656 | | /// a client console window was launched at |
657 | | /// in relation to the other client windows |
658 | | /// and the clients console window handle. |
659 | | /// * `workspace_area` - The available workspace area on the |
660 | | /// primary monitor minus the space occupied |
661 | | /// by the daemon console window. |
662 | 0 | async fn run<W: WindowsApi + Clone + 'static>( |
663 | 0 | &mut self, |
664 | 0 | windows_api: &W, |
665 | 0 | clients: &mut Arc<Mutex<Clients>>, |
666 | 0 | workspace_area: &workspace::WorkspaceArea, |
667 | 0 | ) { |
668 | 0 | let (sender, _) = |
669 | 0 | broadcast::channel::<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>(SENDER_CAPACITY); |
670 | | |
671 | 0 | let mut servers = Arc::new(Mutex::new( |
672 | 0 | self.launch_named_pipe_servers(&sender, Arc::clone(clients)), |
673 | | )); |
674 | | |
675 | | // Monitor client processes |
676 | 0 | let clients_clone = Arc::clone(clients); |
677 | 0 | let windows_api_clone = windows_api.clone(); |
678 | 0 | tokio::spawn(async move { |
679 | | loop { |
680 | 0 | clients_clone.lock().unwrap().retain(|client| { |
681 | 0 | match windows_api_clone.get_exit_code(client.process_handle) { |
682 | 0 | Ok(exit_code) => return exit_code == STILL_ACTIVE.0 as u32, |
683 | 0 | Err(_) => return false, // Process handle is invalid, remove client |
684 | | } |
685 | 0 | }); |
686 | 0 | if clients_clone.lock().unwrap().is_empty() { |
687 | | // All clients have exited, exit the daemon as well |
688 | 0 | std::process::exit(0); |
689 | 0 | } |
690 | 0 | tokio::time::sleep(Duration::from_millis(5)).await; |
691 | | } |
692 | | }); |
693 | | |
694 | 0 | ensure_client_z_order_in_sync_with_daemon( |
695 | 0 | Arc::new(windows_api.clone()), |
696 | 0 | clients.to_owned(), |
697 | | ); |
698 | | |
699 | | loop { |
700 | 0 | self.handle_input_record( |
701 | 0 | windows_api, |
702 | 0 | &sender, |
703 | 0 | read_keyboard_input(windows_api), |
704 | 0 | clients, |
705 | 0 | workspace_area, |
706 | 0 | &mut servers, |
707 | 0 | ) |
708 | 0 | .await; |
709 | | } |
710 | | } |
711 | | |
712 | | /// Launch a named pipe server for each host in a dedicated thread. |
713 | | /// |
714 | | /// # Arguments |
715 | | /// |
716 | | /// * `sender` - The sender end of the broadcast channel through which |
717 | | /// the main thread will send the input records that are to |
718 | | /// be forwarded to the clients. |
719 | | /// |
720 | | /// # Returns |
721 | | /// |
722 | | /// Returns a list of [JoinHandle]s, one handle for each thread. |
723 | 0 | fn launch_named_pipe_servers( |
724 | 0 | &self, |
725 | 0 | sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
726 | 0 | clients: Arc<Mutex<Clients>>, |
727 | 0 | ) -> Vec<JoinHandle<()>> { |
728 | 0 | let mut servers: Vec<JoinHandle<()>> = Vec::new(); |
729 | 0 | for _ in &self.hosts { |
730 | 0 | self.launch_named_pipe_server(&mut servers, sender, Arc::clone(&clients)); |
731 | 0 | } |
732 | 0 | return servers; |
733 | 0 | } |
734 | | |
735 | | /// Launch a named pipe server in a dedicated thread. |
736 | | /// |
737 | | /// # Arguments |
738 | | /// |
739 | | /// * `servers` - A list of [JoinHandle]s to which the join handle for |
740 | | /// the new thread will be added. |
741 | | /// * `sender` - The sender end of the broadcast channel through which |
742 | | /// the main thread will send the input records that are to |
743 | | /// be forwarded to the clients. |
744 | 0 | fn launch_named_pipe_server( |
745 | 0 | &self, |
746 | 0 | servers: &mut Vec<JoinHandle<()>>, |
747 | 0 | sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
748 | 0 | clients: Arc<Mutex<Clients>>, |
749 | 0 | ) { |
750 | 0 | let named_pipe_server = ServerOptions::new() |
751 | 0 | .access_inbound(true) |
752 | 0 | .access_outbound(true) |
753 | 0 | .pipe_mode(PipeMode::Message) |
754 | 0 | .create(PIPE_NAME) |
755 | 0 | .unwrap_or_else(|err| { |
756 | 0 | error!("{}", err); |
757 | 0 | panic!("Failed to create named pipe server",) |
758 | | }); |
759 | 0 | let mut receiver = sender.subscribe(); |
760 | 0 | servers.push(tokio::spawn(async move { |
761 | 0 | named_pipe_server_routine(named_pipe_server, &mut receiver, clients).await; |
762 | 0 | })); |
763 | 0 | } |
764 | | |
765 | | /// Handle the given input record. |
766 | | /// |
767 | | /// Input records are being forwarded to all clients. |
768 | | /// If a sequence of input records matches the control mode |
769 | | /// key combination, forwarding is temporarily interrupted, |
770 | | /// until control mode is exited. |
771 | | /// |
772 | | /// # Arguments |
773 | | /// |
774 | | /// * `sender` - The sender end of the broadcast channel |
775 | | /// through which we will send the input records |
776 | | /// that are being forwarded to the clients |
777 | | /// by the named pipe servers (`servers`). |
778 | | /// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` read from the |
779 | | /// console input buffer. |
780 | | /// * `clients` - A thread safe mapping from the number |
781 | | /// a client console window was launched at |
782 | | /// in relation to the other client windows |
783 | | /// and the clients console window handle. |
784 | | /// The mapping will be extended if additional clients |
785 | | /// are being added through control mode `[c]reate window(s)`. |
786 | | /// * `workspace_area` - The available workspace area on the |
787 | | /// primary monitor minus the space occupied |
788 | | /// by the daemon console window. |
789 | | /// * `servers` - A thread safe list of [JoinHandle]s, |
790 | | /// one handle for each named pipe server background thread. |
791 | | /// The list will be extended if additional clients are being added |
792 | | /// through control mode `[c]reate window(s)`. |
793 | 0 | async fn handle_input_record<W: WindowsApi + Clone + 'static>( |
794 | 0 | &mut self, |
795 | 0 | windows_api: &W, |
796 | 0 | sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
797 | 0 | input_record: INPUT_RECORD_0, |
798 | 0 | clients: &mut Arc<Mutex<Clients>>, |
799 | 0 | workspace_area: &workspace::WorkspaceArea, |
800 | 0 | servers: &mut Arc<Mutex<Vec<JoinHandle<()>>>>, |
801 | 0 | ) { |
802 | 0 | if self.control_mode_is_active(windows_api, clients, input_record) { |
803 | 0 | if self.control_mode_state == ControlModeState::Initiated { |
804 | 0 | clear_screen(windows_api); |
805 | 0 | println!("Control Mode (Esc to exit)"); |
806 | 0 | println!( |
807 | | "[c]reate window(s), [r]etile, [e]nable/disable input, [t]oggle enabled, e[n]able all, copy active [h]ostname(s)" |
808 | | ); |
809 | 0 | self.control_mode_state = ControlModeState::Active; |
810 | 0 | return; |
811 | 0 | } |
812 | 0 | let key_event = unsafe { input_record.KeyEvent }; |
813 | 0 | if !key_event.bKeyDown.as_bool() { |
814 | 0 | return; |
815 | 0 | } |
816 | 0 | if matches!( |
817 | 0 | self.control_mode_state, |
818 | | ControlModeState::EnableDisableSubmenu { .. } |
819 | | ) { |
820 | 0 | self.handle_enable_disable_submenu_key( |
821 | 0 | windows_api, |
822 | 0 | clients, |
823 | 0 | workspace_area, |
824 | 0 | key_event, |
825 | | ); |
826 | 0 | return; |
827 | 0 | } |
828 | 0 | match classify_control_mode_key( |
829 | 0 | VIRTUAL_KEY(key_event.wVirtualKeyCode), |
830 | 0 | key_event.dwControlKeyState, |
831 | 0 | ) { |
832 | 0 | ControlModeAction::Retile => { |
833 | 0 | self.rearrange_client_windows( |
834 | 0 | windows_api, |
835 | 0 | &mut clients.lock().unwrap(), |
836 | 0 | workspace_area, |
837 | 0 | ); |
838 | 0 | self.arrange_daemon_console(windows_api, workspace_area); |
839 | 0 | } |
840 | | ControlModeAction::OpenEnableDisableSubmenu => { |
841 | 0 | let clients_guard = clients.lock().unwrap(); |
842 | 0 | let grid = build_client_grid( |
843 | 0 | &clients_guard, |
844 | 0 | workspace_area, |
845 | 0 | self.config.aspect_ratio_adjustment, |
846 | | ); |
847 | 0 | let next_pid = grid.top_left_pid(); |
848 | 0 | let anchor_col = next_pid |
849 | 0 | .and_then(|p| return grid.cell(p)) |
850 | 0 | .map(|c| return grid.anchor_for(c)); |
851 | 0 | self.apply_submenu_highlight(&clients_guard, None, next_pid); |
852 | 0 | self.control_mode_state = ControlModeState::EnableDisableSubmenu { |
853 | 0 | highlighted_pid: next_pid, |
854 | 0 | anchor_col, |
855 | 0 | }; |
856 | 0 | self.render_enable_disable_submenu(windows_api); |
857 | | } |
858 | | ControlModeAction::ToggleEnabled => { |
859 | | // Snapshot before flipping so each client toggles relative |
860 | | // to its own pre-loop state, not to writes this loop has |
861 | | // already made. |
862 | 0 | self.update_client_states(clients, |clients_guard| { |
863 | 0 | return clients_guard |
864 | 0 | .iter() |
865 | 0 | .map(|client| { |
866 | 0 | let flipped = match *client.state_sender.borrow() { |
867 | 0 | ClientState::Active => ClientState::Disabled, |
868 | 0 | ClientState::Disabled => ClientState::Active, |
869 | | }; |
870 | 0 | return (client.process_id, flipped); |
871 | 0 | }) |
872 | 0 | .collect(); |
873 | 0 | }); |
874 | 0 | self.quit_control_mode(windows_api); |
875 | | } |
876 | | ControlModeAction::EnableAll => { |
877 | 0 | self.update_client_states(clients, |clients_guard| { |
878 | 0 | return clients_guard |
879 | 0 | .iter() |
880 | 0 | .map(|client| return (client.process_id, ClientState::Active)) |
881 | 0 | .collect(); |
882 | 0 | }); |
883 | 0 | self.quit_control_mode(windows_api); |
884 | | } |
885 | | ControlModeAction::CreateWindows => { |
886 | 0 | clear_screen(windows_api); |
887 | | // TODO: make ESC abort |
888 | 0 | println!("Hostname(s) or cluster tag(s): (leave empty to abort)"); |
889 | 0 | toggle_processed_input_mode(windows_api); // As it was disabled before, this enables it again |
890 | 0 | let mut hostnames = String::new(); |
891 | 0 | match io::stdin().read_line(&mut hostnames) { |
892 | 0 | Ok(2) => { |
893 | 0 | // Empty input (only newline '\n') |
894 | 0 | } |
895 | | Ok(_) => { |
896 | 0 | let number_of_existing_clients = clients.lock().unwrap().len(); |
897 | 0 | let new_clients = launch_clients( |
898 | 0 | windows_api, |
899 | 0 | expand_hosts( |
900 | 0 | hostnames.split(' ').map(|x| return x.trim()).collect(), |
901 | 0 | self.clusters, |
902 | | ), |
903 | 0 | &self.username, |
904 | 0 | self.port, |
905 | 0 | self.debug, |
906 | 0 | workspace_area, |
907 | 0 | self.config.aspect_ratio_adjustment, |
908 | 0 | number_of_existing_clients, |
909 | | ) |
910 | 0 | .await; |
911 | 0 | for client in new_clients.into_iter() { |
912 | 0 | clients.lock().unwrap().push(client); |
913 | 0 | self.launch_named_pipe_server( |
914 | 0 | &mut servers.lock().unwrap(), |
915 | 0 | sender, |
916 | 0 | Arc::clone(clients), |
917 | 0 | ); |
918 | 0 | } |
919 | | } |
920 | 0 | Err(error) => { |
921 | 0 | error!("{error}"); |
922 | | } |
923 | | } |
924 | 0 | toggle_processed_input_mode(windows_api); // Re-disable processed input mode. |
925 | 0 | self.rearrange_client_windows( |
926 | 0 | windows_api, |
927 | 0 | &mut clients.lock().unwrap(), |
928 | 0 | workspace_area, |
929 | | ); |
930 | 0 | self.arrange_daemon_console(windows_api, workspace_area); |
931 | | // Focus the daemon console again. |
932 | 0 | let daemon_window = windows_api.get_console_window(); |
933 | 0 | let _ = windows_api.bring_window_to_top(daemon_window, true); |
934 | 0 | self.quit_control_mode(windows_api); |
935 | | } |
936 | | ControlModeAction::CopyHostnames => { |
937 | 0 | let mut active_hostnames: Vec<String> = vec![]; |
938 | 0 | for client in clients.lock().unwrap().iter() { |
939 | 0 | if windows_api.is_window(client.window_handle) { |
940 | 0 | active_hostnames.push(client.hostname.clone()); |
941 | 0 | } |
942 | | } |
943 | 0 | cli_clipboard::set_contents(active_hostnames.join(" ")).unwrap(); |
944 | 0 | self.quit_control_mode(windows_api); |
945 | | } |
946 | 0 | ControlModeAction::NoOp => {} |
947 | | } |
948 | 0 | return; |
949 | 0 | } |
950 | 0 | let error_handler = |err| { |
951 | 0 | error!("{}", err); |
952 | 0 | panic!( |
953 | | "Failed to serialize input recored `{}`", |
954 | 0 | input_record.string_repr() |
955 | | ) |
956 | | }; |
957 | 0 | match sender.send( |
958 | 0 | serialize_input_record_0(&input_record)[..] |
959 | 0 | .try_into() |
960 | 0 | .unwrap_or_else(error_handler), |
961 | 0 | ) { |
962 | 0 | Ok(_) => {} |
963 | 0 | Err(_) => { |
964 | 0 | thread::sleep(time::Duration::from_nanos(1)); |
965 | 0 | } |
966 | | } |
967 | 0 | } |
968 | | |
969 | | /// Updates `self.control_mode_state` for the given input record and |
970 | | /// reports whether control mode owned the keystroke. |
971 | | /// |
972 | | /// Entering control mode requires this function to be called twice |
973 | | /// because the activating chord `Ctrl + A` produces two input |
974 | | /// records (the modifier press and the `A` key). Once active, every |
975 | | /// subsequent key - including the `Esc` that exits control mode - |
976 | | /// is reported as consumed so callers do not forward it to clients. |
977 | | /// |
978 | | /// # Arguments |
979 | | /// |
980 | | /// * `windows_api` - The Windows API implementation to use. |
981 | | /// * `clients` - Currently tracked clients. Used to clear the |
982 | | /// submenu highlight on the previously-selected |
983 | | /// client when `Esc` exits the enable/disable |
984 | | /// submenu. |
985 | | /// * `input_record` - A KeyEvent input record. |
986 | | /// |
987 | | /// # Returns |
988 | | /// |
989 | | /// Whether the input record was consumed by control mode. Returns |
990 | | /// `true` while control mode is active (including the `Esc` |
991 | | /// keystroke that exits it), so callers must not forward such |
992 | | /// records to clients. |
993 | 1 | fn control_mode_is_active<W: WindowsApi>( |
994 | 1 | &mut self, |
995 | 1 | windows_api: &W, |
996 | 1 | clients: &Mutex<Clients>, |
997 | 1 | input_record: INPUT_RECORD_0, |
998 | 1 | ) -> bool { |
999 | 1 | let key_event = unsafe { input_record.KeyEvent }; |
1000 | 1 | if self.control_mode_state == ControlModeState::Active |
1001 | 0 | || matches!( |
1002 | 0 | self.control_mode_state, |
1003 | | ControlModeState::EnableDisableSubmenu { .. } |
1004 | | ) |
1005 | | { |
1006 | 1 | if key_event.wVirtualKeyCode == VK_ESCAPE.0 { |
1007 | | if let ControlModeState::EnableDisableSubmenu { |
1008 | 0 | highlighted_pid, .. |
1009 | 1 | } = self.control_mode_state |
1010 | 0 | { |
1011 | 0 | let clients_guard = clients.lock().unwrap(); |
1012 | 0 | self.apply_submenu_highlight(&clients_guard, highlighted_pid, None); |
1013 | 1 | } |
1014 | 1 | self.quit_control_mode(windows_api); |
1015 | 1 | return true; |
1016 | 0 | } |
1017 | 0 | return true; |
1018 | 0 | } |
1019 | 0 | if (key_event.dwControlKeyState & LEFT_CTRL_PRESSED >= 1 |
1020 | 0 | || key_event.dwControlKeyState & RIGHT_CTRL_PRESSED >= 1) |
1021 | 0 | && key_event.wVirtualKeyCode == VK_A.0 |
1022 | | { |
1023 | 0 | self.control_mode_state = ControlModeState::Initiated; |
1024 | 0 | return true; |
1025 | 0 | } |
1026 | 0 | return false; |
1027 | 1 | } |
1028 | | |
1029 | | /// Prints the default daemon instructions to the daemon console. |
1030 | | /// |
1031 | | /// # Arguments |
1032 | | /// |
1033 | | /// * `windows_api` - Windows API used to clear and redraw the |
1034 | | /// daemon console. |
1035 | 2 | fn quit_control_mode<W: WindowsApi>(&mut self, windows_api: &W) { |
1036 | 2 | self.print_instructions(windows_api); |
1037 | 2 | self.control_mode_state = ControlModeState::Inactive; |
1038 | 2 | } |
1039 | | |
1040 | | /// Clears the console screen and prints the default daemon instructions. |
1041 | 2 | fn print_instructions<W: WindowsApi>(&self, windows_api: &W) { |
1042 | 2 | clear_screen(windows_api); |
1043 | 2 | println!("Input to terminal: (Ctrl-A to enter control mode)"); |
1044 | 2 | } |
1045 | | |
1046 | | /// Iterates over all still open client windows and re-arranges them |
1047 | | /// on the screen based on the aspect ration adjustment daemon configuration. |
1048 | | /// |
1049 | | /// Client windows will be re-sized and re-positioned. |
1050 | | /// |
1051 | | /// # Arguments |
1052 | | /// |
1053 | | /// * `windows_api` - The Windows API implementation to use |
1054 | | /// * `clients` - A thread safe mapping from the number |
1055 | | /// a client console window was launched at |
1056 | | /// in relation to the other client windows |
1057 | | /// and the clients console window handle. |
1058 | | /// The number is relevant to determine the |
1059 | | /// position on the screen the window should |
1060 | | /// be placed at. |
1061 | | /// * `workspace_area` - The available workspace area on the |
1062 | | /// primary monitor minus the space occupied |
1063 | | /// by the daemon console window. |
1064 | 0 | fn rearrange_client_windows<W: WindowsApi>( |
1065 | 0 | &self, |
1066 | 0 | windows_api: &W, |
1067 | 0 | clients: &mut Clients, |
1068 | 0 | workspace_area: &workspace::WorkspaceArea, |
1069 | 0 | ) { |
1070 | 0 | clients.retain(|client| { |
1071 | 0 | let exit_code = match windows_api.get_exit_code(client.process_handle) { |
1072 | 0 | Ok(code) => code, |
1073 | 0 | Err(_) => return false, |
1074 | | }; |
1075 | 0 | return exit_code == STILL_ACTIVE.0 as u32 |
1076 | 0 | && windows_api.is_window(client.window_handle); |
1077 | 0 | }); |
1078 | 0 | let valid_layout: Vec<(u32, HWND)> = clients |
1079 | 0 | .iter() |
1080 | 0 | .map(|c| return (c.process_id, c.window_handle)) |
1081 | 0 | .collect(); |
1082 | 0 | let valid_pids: Vec<u32> = valid_layout.iter().map(|(pid, _)| return *pid).collect(); |
1083 | 0 | clients.reset_tile_layout(&valid_pids); |
1084 | 0 | for (index, (_, window_handle)) in valid_layout.iter().enumerate() { |
1085 | 0 | arrange_client_window( |
1086 | 0 | windows_api, |
1087 | 0 | window_handle, |
1088 | 0 | workspace_area, |
1089 | 0 | index, |
1090 | 0 | valid_layout.len(), |
1091 | 0 | self.config.aspect_ratio_adjustment, |
1092 | | ) |
1093 | | } |
1094 | 0 | } |
1095 | | |
1096 | | /// Dispatches a key press received while the daemon is in the |
1097 | | /// [`ControlModeState::EnableDisableSubmenu`] state. `[e]/[d]/[t]` |
1098 | | /// act on the currently selected client; `Navigate` moves the |
1099 | | /// selection and redraws the prompt. The submenu is left via |
1100 | | /// `ESC`, which is handled by the caller. |
1101 | | /// |
1102 | | /// # Arguments |
1103 | | /// |
1104 | | /// * `windows_api` - Windows API implementation used by the |
1105 | | /// render helper when redrawing after navigation. |
1106 | | /// * `clients` - Shared client collection. Empty lists are a |
1107 | | /// no-op for every action. |
1108 | | /// * `key_event` - The key-down [`KEY_EVENT_RECORD`] dispatched |
1109 | | /// from `handle_input_record`. |
1110 | 11 | fn handle_enable_disable_submenu_key<W: WindowsApi>( |
1111 | 11 | &mut self, |
1112 | 11 | windows_api: &W, |
1113 | 11 | clients: &Mutex<Clients>, |
1114 | 11 | workspace_area: &workspace::WorkspaceArea, |
1115 | 11 | key_event: KEY_EVENT_RECORD, |
1116 | 11 | ) { |
1117 | | let ControlModeState::EnableDisableSubmenu { |
1118 | 11 | highlighted_pid, |
1119 | 11 | anchor_col, |
1120 | 11 | } = self.control_mode_state |
1121 | | else { |
1122 | 0 | return; |
1123 | | }; |
1124 | 11 | match classify_enable_disable_submenu_key( |
1125 | 11 | VIRTUAL_KEY(key_event.wVirtualKeyCode), |
1126 | 11 | key_event.dwControlKeyState, |
1127 | 11 | ) { |
1128 | | EnableDisableSubmenuAction::Enable => { |
1129 | 4 | self.update_client_states(clients, |clients_guard| { |
1130 | 4 | return highlighted_pid |
1131 | 4 | .and_then(|pid| return clients_guard3 .get_by_pid3 (pid3 )) |
1132 | 4 | .map(|client| return vec!3 [(client.process_id, ClientState::Active)3 ]) |
1133 | 4 | .unwrap_or_default(); |
1134 | 4 | }); |
1135 | | } |
1136 | | EnableDisableSubmenuAction::Disable => { |
1137 | 1 | self.update_client_states(clients, |clients_guard| { |
1138 | 1 | return highlighted_pid |
1139 | 1 | .and_then(|pid| return clients_guard.get_by_pid(pid)) |
1140 | 1 | .map(|client| return vec![(client.process_id, ClientState::Disabled)]) |
1141 | 1 | .unwrap_or_default(); |
1142 | 1 | }); |
1143 | | } |
1144 | | EnableDisableSubmenuAction::Toggle => { |
1145 | 2 | self.update_client_states(clients, |clients_guard| { |
1146 | 2 | return highlighted_pid |
1147 | 2 | .and_then(|pid| return clients_guard.get_by_pid(pid)) |
1148 | 2 | .map(|client| { |
1149 | 2 | let flipped = match *client.state_sender.borrow() { |
1150 | 1 | ClientState::Active => ClientState::Disabled, |
1151 | 1 | ClientState::Disabled => ClientState::Active, |
1152 | | }; |
1153 | 2 | return vec![(client.process_id, flipped)]; |
1154 | 2 | }) |
1155 | 2 | .unwrap_or_default(); |
1156 | 2 | }); |
1157 | | } |
1158 | 3 | EnableDisableSubmenuAction::Navigate(direction) => { |
1159 | 3 | let clients_guard = clients.lock().unwrap(); |
1160 | 3 | let grid = build_client_grid( |
1161 | 3 | &clients_guard, |
1162 | 3 | workspace_area, |
1163 | 3 | self.config.aspect_ratio_adjustment, |
1164 | 3 | ); |
1165 | 3 | let (next_pid, next_anchor) = next_submenu_selection( |
1166 | 3 | &grid, |
1167 | 3 | highlighted_pid, |
1168 | 3 | anchor_col, |
1169 | 3 | direction, |
1170 | 3 | self.config.submenu_edge_behavior, |
1171 | 3 | ); |
1172 | 3 | self.apply_submenu_highlight(&clients_guard, highlighted_pid, next_pid); |
1173 | 3 | self.control_mode_state = ControlModeState::EnableDisableSubmenu { |
1174 | 3 | highlighted_pid: next_pid, |
1175 | 3 | anchor_col: next_anchor, |
1176 | 3 | }; |
1177 | 3 | self.render_enable_disable_submenu(windows_api); |
1178 | 3 | } |
1179 | 1 | EnableDisableSubmenuAction::NoOp => {} |
1180 | | } |
1181 | 11 | } |
1182 | | |
1183 | | /// Redraws the enable/disable submenu prompt. |
1184 | | /// |
1185 | | /// # Arguments |
1186 | | /// |
1187 | | /// * `windows_api` - Windows API used to clear the console. |
1188 | 3 | fn render_enable_disable_submenu<W: WindowsApi>(&self, windows_api: &W) { |
1189 | 3 | clear_screen(windows_api); |
1190 | 3 | println!("Enable/Disable input (Esc to exit)"); |
1191 | 3 | println!("[e]nable, [d]isable, [t]oggle, arrows/hjkl to move"); |
1192 | 3 | } |
1193 | | |
1194 | | /// Move the per-client highlight from `prev_pid` to `next_pid`. |
1195 | | /// |
1196 | | /// PID-based clearing tolerates the background monitor's `retain` |
1197 | | /// shifting indices while the submenu is open. |
1198 | | /// |
1199 | | /// # Arguments |
1200 | | /// |
1201 | | /// * `clients` - Currently tracked clients. |
1202 | | /// * `prev_pid` - PID currently highlighted, or `None` if no |
1203 | | /// client is highlighted. |
1204 | | /// * `next_pid` - PID to highlight now, or `None` to clear the |
1205 | | /// highlight entirely. |
1206 | 7 | fn apply_submenu_highlight( |
1207 | 7 | &self, |
1208 | 7 | clients: &Clients, |
1209 | 7 | prev_pid: Option<u32>, |
1210 | 7 | next_pid: Option<u32>, |
1211 | 7 | ) { |
1212 | 7 | if let Some(prev_pid6 ) = prev_pid { |
1213 | 6 | if Some(prev_pid) != next_pid { |
1214 | 6 | if let Some(prev_client4 ) = clients.get_by_pid(prev_pid) { |
1215 | 4 | prev_client.highlight_sender.send_replace(false); |
1216 | 4 | }2 |
1217 | 0 | } |
1218 | 1 | } |
1219 | 7 | if let Some(next_pid6 ) = next_pid { |
1220 | 6 | if let Some(client) = clients.get_by_pid(next_pid) { |
1221 | 6 | client.highlight_sender.send_replace(true); |
1222 | 6 | }0 |
1223 | 1 | } |
1224 | 7 | } |
1225 | | |
1226 | | /// Apply a batch of [`ClientState`] updates while holding the |
1227 | | /// [`Clients`] mutex exactly once. |
1228 | | /// |
1229 | | /// `f` is called with the locked guard and returns the list of |
1230 | | /// `(pid, new_state)` updates to apply. The guard is held across both |
1231 | | /// the build and the apply phase so callers see a stable snapshot. |
1232 | | /// |
1233 | | /// # Arguments |
1234 | | /// |
1235 | | /// * `clients` - Shared client collection. |
1236 | | /// * `f` - Builds the updates from a `&Clients` snapshot. |
1237 | 7 | fn update_client_states<F>(&self, clients: &Mutex<Clients>, f: F) |
1238 | 7 | where |
1239 | 7 | F: FnOnce(&Clients) -> Vec<(u32, ClientState)>, |
1240 | | { |
1241 | 7 | let clients_guard = clients.lock().unwrap(); |
1242 | 7 | let updates = f(&clients_guard); |
1243 | 7 | for (pid6 , state6 ) in updates { |
1244 | 6 | self.set_client_state(&clients_guard, pid, state); |
1245 | 6 | } |
1246 | 7 | } |
1247 | | |
1248 | | /// Push a new [`ClientState`] for the client identified by `pid`. |
1249 | | /// |
1250 | | /// Looks the client up by PID and broadcasts the new state through its |
1251 | | /// [`watch::Sender`]. The pipe-server task subscribed to that sender |
1252 | | /// observes the change and forwards a [`cssh_rs_protocol::TAG_STATE_CHANGE`] |
1253 | | /// frame to the client over the named pipe. Called from the |
1254 | | /// control-mode handlers for `[t]oggle enabled` and `e[n]able all` via |
1255 | | /// [`Daemon::update_client_states`]. |
1256 | | /// |
1257 | | /// # Arguments |
1258 | | /// |
1259 | | /// * `clients` - The daemon's tracked clients. |
1260 | | /// * `pid` - Process id of the client whose state should change. |
1261 | | /// * `state` - The new state to broadcast. |
1262 | 6 | fn set_client_state(&self, clients: &Clients, pid: u32, state: ClientState) { |
1263 | 6 | if let Some(client) = clients.get_by_pid(pid) { |
1264 | 6 | // `send_replace` always updates the stored value (unlike `send`, |
1265 | 6 | // which returns `Err` and leaves the value untouched when there |
1266 | 6 | // are no active receivers). This matters during the brief window |
1267 | 6 | // between [`Client`] construction and the pipe-server task's |
1268 | 6 | // `subscribe()`: any state change pushed in that window must |
1269 | 6 | // still be visible to the next subscriber via `borrow`. |
1270 | 6 | client.state_sender.send_replace(state); |
1271 | 6 | }0 |
1272 | 6 | } |
1273 | | |
1274 | | /// Re-sizes and re-positions the daemon console window on the screen |
1275 | | /// based on the daemon height configuration. |
1276 | | /// |
1277 | | /// # Arguments |
1278 | | /// |
1279 | | /// * `windows_api` - The Windows API implementation to use |
1280 | | /// * `workspace_area` - The available workspace area on the |
1281 | | /// primary monitor minus the space occupied |
1282 | | /// by the daemon console window. |
1283 | 0 | fn arrange_daemon_console<W: WindowsApi>( |
1284 | 0 | &self, |
1285 | 0 | windows_api: &W, |
1286 | 0 | workspace_area: &WorkspaceArea, |
1287 | 0 | ) { |
1288 | 0 | let (x, y, width, height) = get_console_rect( |
1289 | 0 | 0, |
1290 | 0 | workspace_area.height, |
1291 | 0 | workspace_area.width - (workspace_area.x_fixed_frame + workspace_area.x_size_frame), |
1292 | 0 | self.config.height, |
1293 | 0 | workspace_area, |
1294 | 0 | ); |
1295 | 0 | arrange_console(windows_api, x, y, width, height); |
1296 | 0 | } |
1297 | | } |
1298 | | |
1299 | | /// The processed console input mode controls whether special key combinations |
1300 | | /// such as `Ctrl + c` or `Ctrl + BREAK` receive special handling or are treated |
1301 | | /// as simple key presses. |
1302 | | /// |
1303 | | /// By default processed input mode is enabled, meaning `Ctrl + c` is treated as |
1304 | | /// a signal, not key presses. |
1305 | | /// |
1306 | | /// <https://learn.microsoft.com/en-us/windows/console/ctrl-c-and-ctrl-break-signals> |
1307 | | /// |
1308 | | /// # Arguments |
1309 | | /// |
1310 | | /// * `windows_api` - The Windows API implementation to use |
1311 | 0 | fn toggle_processed_input_mode<W: WindowsApi>(windows_api: &W) { |
1312 | 0 | let handle = get_console_input_buffer(); |
1313 | 0 | let mode = windows_api.get_console_mode(handle).unwrap(); |
1314 | 0 | let new_mode = windows::Win32::System::Console::CONSOLE_MODE(mode.0 ^ ENABLE_PROCESSED_INPUT.0); |
1315 | 0 | let _ = windows_api.set_console_mode(handle, new_mode); |
1316 | 0 | } |
1317 | | |
1318 | | /// Resolve cluster tags into hostnames |
1319 | | /// |
1320 | | /// Iterates over the list of hosts to find and resolve cluster tags. |
1321 | | /// Nested cluster tags are supported but recursivness is not checked for. |
1322 | | /// |
1323 | | /// # Arguments |
1324 | | /// |
1325 | | /// * `hosts` - List of hosts including hostnames and or cluster tags |
1326 | | /// * `clusters` - List of available cluster tags |
1327 | | /// |
1328 | | /// # Returns |
1329 | | /// |
1330 | | /// A list of hostnames |
1331 | 18 | pub fn resolve_cluster_tags<'a>(hosts: Vec<&'a str>, clusters: &'a [Cluster]) -> Vec<&'a str> { |
1332 | 18 | let mut resolved_hosts: Vec<&str> = Vec::new(); |
1333 | | let mut is_cluster_tag: bool; |
1334 | 31 | for host in hosts18 { |
1335 | 31 | is_cluster_tag = false; |
1336 | 31 | for cluster23 in clusters { |
1337 | 23 | if host == cluster.name { |
1338 | 5 | is_cluster_tag = true; |
1339 | 5 | resolved_hosts.extend(resolve_cluster_tags( |
1340 | 9 | cluster.hosts.iter()5 .map5 (|host| return &**host).collect5 (), |
1341 | 5 | clusters, |
1342 | | )); |
1343 | 5 | break; |
1344 | 18 | } |
1345 | | } |
1346 | 31 | if !is_cluster_tag { |
1347 | 26 | resolved_hosts.push(host); |
1348 | 26 | }5 |
1349 | | } |
1350 | 18 | return resolved_hosts; |
1351 | 18 | } |
1352 | | |
1353 | | /// Resolve cluster tags in `hosts` and expand brace expressions |
1354 | | /// (e.g. `host{1..3}.local`) in each resulting hostname. |
1355 | | /// |
1356 | | /// Used by the control-mode `[c]reate window(s)` path so hostname |
1357 | | /// input behaves the same as on the CLI. Each cluster-resolved |
1358 | | /// hostname is passed through [`bracoxide::explode`] individually; |
1359 | | /// hostnames that do not contain a brace expression are kept as-is. |
1360 | | /// |
1361 | | /// # Arguments |
1362 | | /// |
1363 | | /// * `hosts` - User-supplied hostnames and/or cluster tags. |
1364 | | /// * `clusters` - Available cluster definitions. |
1365 | | /// |
1366 | | /// # Returns |
1367 | | /// |
1368 | | /// The fully resolved, brace-expanded list of hostnames. |
1369 | 4 | pub fn expand_hosts(hosts: Vec<&str>, clusters: &[Cluster]) -> Vec<String> { |
1370 | 4 | return resolve_cluster_tags(hosts, clusters) |
1371 | 4 | .into_iter() |
1372 | 7 | .flat_map4 (|host| return explode(host).unwrap_or_else(|_| return vec!4 [host4 .to_owned4 ()])) |
1373 | 4 | .collect(); |
1374 | 4 | } |
1375 | | |
1376 | | /// Launches a client console for each given host and waits for |
1377 | | /// the client windows to exist before returning their handles. |
1378 | | /// |
1379 | | /// # Arguments |
1380 | | /// |
1381 | | /// * `windows_api` - The Windows API implementation to use |
1382 | | /// * `hosts` - List of hosts |
1383 | | /// * `username` - Optional username, if none is given |
1384 | | /// the client will use the SSH config to |
1385 | | /// determine a username. |
1386 | | /// * `port` - Optional port for SSH connections |
1387 | | /// * `debug` - Toggles debug mode on the client. |
1388 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1389 | | /// minus the space occupied by the daemon console window. |
1390 | | /// Used to arrange the client window. |
1391 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1392 | | /// Used to arrange the client window. |
1393 | | /// * `index_offset` - Offset used to position the new windows correctly |
1394 | | /// from the start, avoiding flickering. |
1395 | | /// |
1396 | | /// # Returns |
1397 | | /// |
1398 | | /// A [`Clients`] collection preserving the launch order and indexed by |
1399 | | /// process id for pipe-server correlation. |
1400 | 0 | async fn launch_clients<W: WindowsApi + 'static + Clone>( |
1401 | 0 | windows_api: &W, |
1402 | 0 | hosts: Vec<String>, |
1403 | 0 | username: &Option<String>, |
1404 | 0 | port: Option<u16>, |
1405 | 0 | debug: bool, |
1406 | 0 | workspace_area: &workspace::WorkspaceArea, |
1407 | 0 | aspect_ratio_adjustment: f64, |
1408 | 0 | index_offset: usize, |
1409 | 0 | ) -> Clients { |
1410 | 0 | let len_hosts = hosts.len(); |
1411 | 0 | let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new(); |
1412 | | |
1413 | | // Create an Arc to share the windows_api across parallel tasks |
1414 | 0 | let windows_api_arc = Arc::new(windows_api.clone()); |
1415 | | |
1416 | | // Create tasks for each client launch using spawn_blocking to handle the synchronous operations |
1417 | 0 | let mut tasks = Vec::new(); |
1418 | | |
1419 | 0 | for (index, host) in hosts.into_iter().enumerate() { |
1420 | 0 | let username_client = username.clone(); |
1421 | 0 | let workspace_area_client = *workspace_area; |
1422 | 0 | let windows_api_clone = Arc::clone(&windows_api_arc); |
1423 | | |
1424 | | // Use spawn_blocking to run the synchronous launch_client_console in parallel |
1425 | 0 | let task = tokio::task::spawn_blocking(move || { |
1426 | 0 | let (window_handle, process_handle, process_id) = launch_client_console( |
1427 | 0 | windows_api_clone.as_ref(), |
1428 | 0 | &host, |
1429 | 0 | username_client, |
1430 | 0 | port, |
1431 | 0 | debug, |
1432 | 0 | index + index_offset, |
1433 | 0 | &workspace_area_client, |
1434 | 0 | len_hosts + index_offset, |
1435 | 0 | aspect_ratio_adjustment, |
1436 | 0 | ); |
1437 | | // The receivers are dropped immediately; pipe-server tasks |
1438 | | // acquire their own receivers via `subscribe()` after PID |
1439 | | // correlation. Holding the senders on the [`Client`] keeps both |
1440 | | // channels alive for the lifetime of the client. |
1441 | 0 | let (state_sender, _state_receiver) = watch::channel(ClientState::Active); |
1442 | 0 | let (highlight_sender, _highlight_receiver) = watch::channel(false); |
1443 | 0 | return ( |
1444 | 0 | index, |
1445 | 0 | Client { |
1446 | 0 | hostname: host, |
1447 | 0 | window_handle, |
1448 | 0 | process_handle, |
1449 | 0 | process_id, |
1450 | 0 | state_sender, |
1451 | 0 | highlight_sender, |
1452 | 0 | // Placeholder - `Clients::push` overwrites with the |
1453 | 0 | // dense `list.len()`-based tile index. |
1454 | 0 | tile_index: 0, |
1455 | 0 | }, |
1456 | 0 | ); |
1457 | 0 | }); |
1458 | | |
1459 | 0 | tasks.push(task); |
1460 | | } |
1461 | | |
1462 | | // Wait for all tasks to complete in parallel |
1463 | 0 | let mut results = Vec::new(); |
1464 | 0 | for task in tasks { |
1465 | 0 | match task.await { |
1466 | 0 | Ok(result) => results.push(result), |
1467 | 0 | Err(e) => panic!("Failed to launch client: {e}"), |
1468 | | } |
1469 | | } |
1470 | | |
1471 | | // Sort results by index to maintain order |
1472 | 0 | results.sort_by_key(|(index, _)| return *index); |
1473 | | |
1474 | 0 | let mut clients = Clients::new(); |
1475 | 0 | for (_, client) in results.into_iter() { |
1476 | 0 | clients.push(client); |
1477 | 0 | } |
1478 | 0 | return clients; |
1479 | 0 | } |
1480 | | |
1481 | | /// Launchs a `client` console process with its own window with the given |
1482 | | /// CLI arguments/options: `host`, `username`, `port`, `debug`. |
1483 | | /// |
1484 | | /// Waits for the window to open, then re-arranges it based on |
1485 | | /// the total number of clients, the size of the daemon console window and |
1486 | | /// its index relative to the other client windows. |
1487 | | /// |
1488 | | /// # Arguments |
1489 | | /// |
1490 | | /// * `windows_api` - The Windows API implementation to use |
1491 | | /// * `host` - Hostname the client should connect to |
1492 | | /// * `username` - Username the client should use |
1493 | | /// * `port` - Optional port for SSH connections |
1494 | | /// * `debug` - Toggle debug mode on the client |
1495 | | /// * `index` - The index of the client in the list of all clients. |
1496 | | /// Used to re-arrange the client window. |
1497 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1498 | | /// minus the space occupied by the daemon console window. |
1499 | | /// * `number_of_consoles` - The total number of active client console windows. |
1500 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1501 | | /// |
1502 | | /// # Returns |
1503 | | /// |
1504 | | /// A tuple containing the window handle, process handle, and process id of the |
1505 | | /// client process. |
1506 | 0 | fn launch_client_console<W: WindowsApi>( |
1507 | 0 | windows_api: &W, |
1508 | 0 | host: &str, |
1509 | 0 | username: Option<String>, |
1510 | 0 | port: Option<u16>, |
1511 | 0 | debug: bool, |
1512 | 0 | index: usize, |
1513 | 0 | workspace_area: &workspace::WorkspaceArea, |
1514 | 0 | number_of_consoles: usize, |
1515 | 0 | aspect_ratio_adjustment: f64, |
1516 | 0 | ) -> (HWND, HANDLE, u32) { |
1517 | | // The first argument must be `--` to ensure all following arguments are treated |
1518 | | // as positional arguments and not as options if they start with `-`. |
1519 | 0 | let mut client_args: Vec<String> = Vec::new(); |
1520 | 0 | if debug { |
1521 | 0 | client_args.push("-d".to_string()); |
1522 | 0 | } |
1523 | 0 | let mut actual_host = host; |
1524 | 0 | let mut actual_username = username; |
1525 | 0 | if let Some(split_result) = host.split_once("@") { |
1526 | 0 | actual_username = Some(split_result.0.to_owned()); |
1527 | 0 | actual_host = split_result.1; |
1528 | 0 | } |
1529 | 0 | if let Some(actual_username) = actual_username.as_deref() { |
1530 | 0 | client_args.extend(vec!["-u".to_string(), actual_username.to_string()]); |
1531 | 0 | } |
1532 | 0 | if let Some(port) = port { |
1533 | 0 | client_args.extend(vec!["-p".to_string(), port.to_string()]); |
1534 | 0 | } |
1535 | 0 | client_args.push("client".to_string()); |
1536 | 0 | client_args.extend(vec!["--".to_string(), actual_host.to_string()]); |
1537 | | |
1538 | 0 | let process_info = spawn_console_process(windows_api, ¤t_exe_path(), client_args, false) |
1539 | 0 | .expect("Failed to create process"); |
1540 | 0 | let client_window_handle = get_console_window_handle(windows_api, process_info.dwProcessId); |
1541 | 0 | let process_handle = windows_api |
1542 | 0 | .open_process(PROCESS_QUERY_INFORMATION.0, false, process_info.dwProcessId) |
1543 | 0 | .unwrap_or_else(|err| { |
1544 | 0 | panic!( |
1545 | | "Failed to open process handle for process {}: {}", |
1546 | | process_info.dwProcessId, err |
1547 | | ); |
1548 | | }); |
1549 | | |
1550 | 0 | arrange_client_window( |
1551 | 0 | windows_api, |
1552 | 0 | &client_window_handle, |
1553 | 0 | workspace_area, |
1554 | 0 | index, |
1555 | 0 | number_of_consoles, |
1556 | 0 | aspect_ratio_adjustment, |
1557 | | ); |
1558 | 0 | return ( |
1559 | 0 | client_window_handle, |
1560 | 0 | process_handle, |
1561 | 0 | process_info.dwProcessId, |
1562 | 0 | ); |
1563 | 0 | } |
1564 | | |
1565 | | /// Correlate the connecting client by PID, then multiplex input records, |
1566 | | /// [`ClientState`] updates, and keep-alives onto the named pipe. |
1567 | | /// |
1568 | | /// The post-subscribe initial-state push is intentional: `state_receiver.changed` |
1569 | | /// only fires on transitions observed *after* `subscribe`, so a state set |
1570 | | /// in the brief window between [`Client`] construction and `subscribe` |
1571 | | /// would otherwise leave the client on its default until the next |
1572 | | /// transition. |
1573 | | /// |
1574 | | /// The `select!` is biased toward `recv` so the keep-alive tick never |
1575 | | /// preempts active input traffic; the [`ClientState::Disabled`] arm |
1576 | | /// therefore probes the pipe itself, otherwise sustained input would |
1577 | | /// hide a disconnect. |
1578 | | /// |
1579 | | /// # Errors and termination |
1580 | | /// |
1581 | | /// An unknown PID exits the process (production) or panics (tests) - |
1582 | | /// the daemon's bookkeeping is broken and recovery is not possible. |
1583 | | /// A failed pipe write or a dropped [`watch::Sender`] ends the routine |
1584 | | /// cleanly. |
1585 | 8 | async fn named_pipe_server_routine( |
1586 | 8 | server: NamedPipeServer, |
1587 | 8 | receiver: &mut Receiver<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
1588 | 8 | clients: Arc<Mutex<Clients>>, |
1589 | 8 | ) { |
1590 | | // wait for a client to connect |
1591 | 8 | server.connect().await.unwrap_or_else(|err| {0 |
1592 | 0 | error!("{}", err); |
1593 | 0 | panic!("Timed out waiting for clients to connect to named pipe server",) |
1594 | | }); |
1595 | | |
1596 | | // Correlate the connecting client by reading its 4 byte PID. |
1597 | 8 | let pid7 = read_client_pid(&server).await; |
1598 | 7 | let (mut state_receiver6 , mut highlight_receiver6 ) = match clients.lock().unwrap().get_by_pid(pid) |
1599 | | { |
1600 | 6 | Some(client) => ( |
1601 | 6 | client.state_sender.subscribe(), |
1602 | 6 | client.highlight_sender.subscribe(), |
1603 | 6 | ), |
1604 | | None => { |
1605 | 1 | error!( |
1606 | | "Named pipe server received unknown PID {} - daemon bookkeeping broken", |
1607 | | pid |
1608 | | ); |
1609 | | // In production this exits the daemon; in tests process::exit would kill |
1610 | | // the test runner, so we panic instead so tokio::spawn can catch it. |
1611 | | #[cfg(not(test))] |
1612 | | std::process::exit(1); |
1613 | | #[cfg(test)] |
1614 | 1 | panic!("Unknown client PID {} - daemon bookkeeping broken", pid); |
1615 | | } |
1616 | | }; |
1617 | | |
1618 | | // Initial state push - see fn docs. |
1619 | 6 | let initial_state = *state_receiver.borrow_and_update(); |
1620 | 6 | let initial_state_frame: [u8; FRAMED_STATE_CHANGE_LENGTH] = |
1621 | 6 | [TAG_STATE_CHANGE, serialize_client_state(initial_state)]; |
1622 | 6 | if !write_framed_message(&server, &initial_state_frame).await { |
1623 | 0 | return; |
1624 | 6 | } |
1625 | | |
1626 | | // Initial highlight push - same rationale as the state push above. |
1627 | 6 | let initial_highlight = *highlight_receiver.borrow_and_update(); |
1628 | 6 | let initial_highlight_frame: [u8; FRAMED_HIGHLIGHT_LENGTH] = |
1629 | 6 | [TAG_HIGHLIGHT, serialize_highlight(initial_highlight)]; |
1630 | 6 | if !write_framed_message(&server, &initial_highlight_frame).await { |
1631 | 0 | return; |
1632 | 6 | } |
1633 | | |
1634 | | loop { |
1635 | | // Independent watch channels: `state_receiver` and `highlight_receiver` are forwarded over the pipe in whichever order this `select!` happens to pick them up, not the order the daemon-side senders fired. |
1636 | 24 | tokio::select! { |
1637 | | biased; |
1638 | 24 | recv_result16 = receiver.recv() => { |
1639 | 14 | let ser_input_record = match recv_result2 { |
1640 | 14 | Ok(val) => val, |
1641 | 1 | Err(RecvError::Lagged(skipped)) => { |
1642 | | // Slow consumers (typically disabled clients) drop |
1643 | | // records rather than kill the routine; debug-level |
1644 | | // because this can fire repeatedly under load. |
1645 | 1 | debug!( |
1646 | | "Named pipe server routine lagged behind broadcast channel - dropping {} record(s)", |
1647 | | skipped |
1648 | | ); |
1649 | | // Probe and yield so sustained lag cannot starve |
1650 | | // the keep-alive tick (the `select!` is `biased` |
1651 | | // toward `recv`) and so a closed pipe is still |
1652 | | // detected promptly under load. |
1653 | 1 | if !probe_pipe_alive(&server) { |
1654 | 0 | return; |
1655 | 1 | } |
1656 | 1 | tokio::task::yield_now().await; |
1657 | 1 | continue; |
1658 | | } |
1659 | | Err(RecvError::Closed) => { |
1660 | 1 | error!("Broadcast channel closed"); |
1661 | 1 | panic!("Failed to receive data from the Receiver"); |
1662 | | } |
1663 | | }; |
1664 | | // Copy out before any `.await` - `watch::Ref` is not `Send`. |
1665 | 14 | let current_state = *state_receiver.borrow(); |
1666 | 14 | match current_state { |
1667 | 7 | ClientState::Active => {} |
1668 | | ClientState::Disabled => { |
1669 | | // Probe the pipe so a disabled client cannot hide a |
1670 | | // disconnect under sustained input - the keep-alive |
1671 | | // tick is starved while recv keeps yielding records. |
1672 | 7 | if !probe_pipe_alive(&server) { |
1673 | 0 | return; |
1674 | 7 | } |
1675 | 7 | tokio::task::yield_now().await; |
1676 | 7 | continue; |
1677 | | } |
1678 | | } |
1679 | 7 | let mut frame = [0u8; FRAMED_INPUT_RECORD_LENGTH]; |
1680 | 7 | frame[0] = TAG_INPUT_RECORD; |
1681 | 7 | frame[1..].copy_from_slice(&ser_input_record); |
1682 | 7 | if !write_framed_message(&server, &frame).await { |
1683 | 0 | return; |
1684 | 7 | } |
1685 | | } |
1686 | 24 | changed_result2 = state_receiver.changed() => { |
1687 | | // Sender dropped - the daemon has removed this client from its |
1688 | | // bookkeeping, so there is nothing left to forward. |
1689 | 2 | if changed_result.is_err() { |
1690 | 0 | debug!( |
1691 | | "Client state sender dropped, stopping named pipe server routine ({:?})", |
1692 | | server |
1693 | | ); |
1694 | 0 | return; |
1695 | 2 | } |
1696 | 2 | let state = *state_receiver.borrow_and_update(); |
1697 | 2 | let frame: [u8; FRAMED_STATE_CHANGE_LENGTH] = |
1698 | 2 | [TAG_STATE_CHANGE, serialize_client_state(state)]; |
1699 | 2 | if !write_framed_message(&server, &frame).await { |
1700 | 0 | return; |
1701 | 2 | } |
1702 | | } |
1703 | 24 | changed_result0 = highlight_receiver.changed() => { |
1704 | | // Sender dropped - same rationale as the `state_receiver` arm. |
1705 | 0 | if changed_result.is_err() { |
1706 | 0 | debug!( |
1707 | | "Client highlight sender dropped, stopping named pipe server routine ({:?})", |
1708 | | server |
1709 | | ); |
1710 | 0 | return; |
1711 | 0 | } |
1712 | 0 | let highlighted = *highlight_receiver.borrow_and_update(); |
1713 | 0 | let frame: [u8; FRAMED_HIGHLIGHT_LENGTH] = |
1714 | 0 | [TAG_HIGHLIGHT, serialize_highlight(highlighted)]; |
1715 | 0 | if !write_framed_message(&server, &frame).await { |
1716 | 0 | return; |
1717 | 0 | } |
1718 | | } |
1719 | 24 | _ = tokio::time::sleep(Duration::from_millis(5)) => { |
1720 | 6 | if !write_framed_message(&server, &[TAG_KEEP_ALIVE]).await { |
1721 | 5 | return; |
1722 | 1 | } |
1723 | | } |
1724 | | } |
1725 | | } |
1726 | 5 | } |
1727 | | |
1728 | | /// Best-effort, non-blocking probe of the named pipe. |
1729 | | /// |
1730 | | /// Returns `true` if a single `TAG_KEEP_ALIVE` byte either wrote |
1731 | | /// successfully or returned `WouldBlock` (the pipe is still open but |
1732 | | /// the OS buffer is full); `false` if any other error indicates the |
1733 | | /// pipe is closed. |
1734 | 8 | fn probe_pipe_alive(server: &NamedPipeServer) -> bool { |
1735 | 8 | match server.try_write(&[TAG_KEEP_ALIVE]) { |
1736 | 7 | Ok(_) => return true, |
1737 | 1 | Err(e) if e.kind() == io::ErrorKind::WouldBlock => return true, |
1738 | | Err(_) => { |
1739 | 0 | debug!( |
1740 | | "Named pipe server ({:?}) is closed, stopping named pipe server routine", |
1741 | | server |
1742 | | ); |
1743 | 0 | return false; |
1744 | | } |
1745 | | } |
1746 | 8 | } |
1747 | | |
1748 | | /// Write all of `frame` to the named pipe server, retrying partial |
1749 | | /// writes and `WouldBlock` results until the buffer is fully drained. |
1750 | | /// |
1751 | | /// Returns `true` on full write, `false` if the pipe is closed. |
1752 | | /// |
1753 | | /// # Panics |
1754 | | /// |
1755 | | /// Panics if waiting for the pipe to become writable returns an error. |
1756 | 27 | async fn write_framed_message(server: &NamedPipeServer, frame: &[u8]) -> bool { |
1757 | 27 | let mut written = 0usize; |
1758 | 63 | while written < frame.len() { |
1759 | 41 | server.writable().await.unwrap_or_else(|err| {0 |
1760 | 0 | error!("{}", err); |
1761 | 0 | panic!("Timed out waiting for named pipe server to become writable",) |
1762 | | }); |
1763 | 41 | match server.try_write(&frame[written..]) { |
1764 | 22 | Ok(n) => { |
1765 | 22 | written += n; |
1766 | 22 | if written < frame.len() { |
1767 | 0 | warn!( |
1768 | | "Partially written data, expected {} but only wrote {} so far", |
1769 | 0 | frame.len(), |
1770 | | written |
1771 | | ); |
1772 | 22 | } |
1773 | | } |
1774 | 19 | Err(e14 ) if e.kind() == io::ErrorKind::WouldBlock14 => { |
1775 | | // Try again |
1776 | 14 | debug!("Writing to named pipe server would have blocked"); |
1777 | 14 | continue; |
1778 | | } |
1779 | | Err(_) => { |
1780 | | // Can happen if the pipe is closed because the |
1781 | | // client exited |
1782 | 5 | debug!( |
1783 | | "Named pipe server ({:?}) is closed, stopping named pipe server routine", |
1784 | | server |
1785 | | ); |
1786 | 5 | return false; |
1787 | | } |
1788 | | } |
1789 | | } |
1790 | 22 | debug!("Successfully written all data"); |
1791 | 22 | return true; |
1792 | 27 | } |
1793 | | |
1794 | | /// Read the connecting client's 4 byte little-endian process id from the pipe. |
1795 | | /// |
1796 | | /// Reads exactly 4 bytes from `server`, retrying on `WouldBlock`, and decodes |
1797 | | /// them as a `u32`. Any non-recoverable I/O error panics, as a client that |
1798 | | /// cannot send its PID cannot be correlated and forwarding would be |
1799 | | /// impossible. |
1800 | | /// |
1801 | | /// # Arguments |
1802 | | /// |
1803 | | /// * `server` - The connected named pipe server to read from. |
1804 | | /// |
1805 | | /// # Returns |
1806 | | /// |
1807 | | /// The process id sent by the client. |
1808 | | /// |
1809 | | /// # Panics |
1810 | | /// |
1811 | | /// Panics if the pipe is closed before 4 bytes can be read, or if any |
1812 | | /// non-`WouldBlock` I/O error occurs. |
1813 | 8 | async fn read_client_pid(server: &NamedPipeServer) -> u32 { |
1814 | 8 | let mut buf = [0u8; SERIALIZED_PID_LENGTH]; |
1815 | 8 | let mut read = 0usize; |
1816 | 15 | while read < SERIALIZED_PID_LENGTH { |
1817 | 8 | server.readable().await.unwrap_or_else(|err| {0 |
1818 | 0 | panic!("Named pipe server is not readable for PID handshake: {err}") |
1819 | | }); |
1820 | 8 | match server.try_read(&mut buf[read..]) { |
1821 | | Ok(0) => { |
1822 | 1 | panic!("Named pipe server closed before PID handshake completed"); |
1823 | | } |
1824 | 7 | Ok(n) => { |
1825 | 7 | read += n; |
1826 | 7 | } |
1827 | 0 | Err(e) if e.kind() == io::ErrorKind::WouldBlock => { |
1828 | 0 | continue; |
1829 | | } |
1830 | 0 | Err(e) => { |
1831 | 0 | panic!("Failed to read PID from named pipe client: {e}"); |
1832 | | } |
1833 | | } |
1834 | | } |
1835 | 7 | return deserialize_pid(&buf); |
1836 | 7 | } |
1837 | | |
1838 | | /// Re-sizes and re-positions the given client window based on the total number of clients, |
1839 | | /// the size of the daemon console window and its index relative to the other client windows. |
1840 | | /// |
1841 | | /// # Arguments |
1842 | | /// |
1843 | | /// * `windows_api` - The Windows API implementation to use |
1844 | | /// * `handle` - Reference the windows handle of a client console window. |
1845 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1846 | | /// minus the space occupied by the daemon console window. |
1847 | | /// * `index` - The index of the client in the list of all clients. |
1848 | | /// * `number_of_consoles` - The total number of active client console windows. |
1849 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1850 | 0 | fn arrange_client_window<W: WindowsApi>( |
1851 | 0 | windows_api: &W, |
1852 | 0 | handle: &HWND, |
1853 | 0 | workspace_area: &workspace::WorkspaceArea, |
1854 | 0 | index: usize, |
1855 | 0 | number_of_consoles: usize, |
1856 | 0 | aspect_ratio_adjustment: f64, |
1857 | 0 | ) { |
1858 | 0 | let (x, y, width, height) = determine_client_spatial_attributes( |
1859 | 0 | index as i32, |
1860 | 0 | number_of_consoles as i32, |
1861 | 0 | workspace_area, |
1862 | 0 | aspect_ratio_adjustment, |
1863 | 0 | ); |
1864 | | // Since windows update 10.0.19041.5072 it can happen that a client windows rendering is broken |
1865 | | // after a move+resize. Why is unclear, but resizing again does solve the issue. |
1866 | | // We first make the window 1 pixel in each dimension too small and imediately fix it. |
1867 | | // To reduce overhead we do not repaint the window the first time. |
1868 | 0 | windows_api |
1869 | 0 | .move_window(*handle, x, y, width - 1, height - 1, false) |
1870 | 0 | .unwrap_or_else(|err| { |
1871 | 0 | error!("{}", err); |
1872 | 0 | panic!("Failed to move window",) |
1873 | | }); |
1874 | 0 | windows_api |
1875 | 0 | .move_window(*handle, x, y, width, height, true) |
1876 | 0 | .unwrap_or_else(|err| { |
1877 | 0 | error!("{}", err); |
1878 | 0 | panic!("Failed to move window",) |
1879 | | }); |
1880 | 0 | } |
1881 | | |
1882 | | /// Return the workspace area's aspect ratio (width / height) including |
1883 | | /// the frame padding the tiler accounts for. |
1884 | | /// |
1885 | | /// # Arguments |
1886 | | /// |
1887 | | /// * `workspace_area` - Available workspace minus the daemon console. |
1888 | | /// |
1889 | | /// # Returns |
1890 | | /// |
1891 | | /// Aspect ratio as a `f64` for use by both the tiler and the navigation |
1892 | | /// grid. |
1893 | 3 | fn workspace_aspect_ratio(workspace_area: &workspace::WorkspaceArea) -> f64 { |
1894 | 3 | return (workspace_area.width + (workspace_area.x_fixed_frame + workspace_area.x_size_frame) * 2) |
1895 | 3 | as f64 |
1896 | 3 | / (workspace_area.height + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * 2) |
1897 | 3 | as f64; |
1898 | 3 | } |
1899 | | |
1900 | | /// Calculates the position and dimensions for a client window given its index, |
1901 | | /// the total number of clients and the `aspect_ratio_adjustment` daemon configuration. |
1902 | | /// |
1903 | | /// # Arguments |
1904 | | /// |
1905 | | /// * `index` - The index of the client in the list of all clients. |
1906 | | /// * `number_of_consoles` - The total number of active client console windows. |
1907 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1908 | | /// minus the space occupied by the daemon console window. |
1909 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1910 | | /// * `> 0.0` - Aims for vertical rectangle shape. |
1911 | | /// The larger the value, the more exaggerated the "verticality". |
1912 | | /// Eventually the windows will all be columns. |
1913 | | /// * `= 0.0` - Aims for square shape. |
1914 | | /// * `< 0.0` - Aims for horizontal rectangle shape. |
1915 | | /// The smaller the value, the more exaggerated the "horizontality". |
1916 | | /// Eventually the windows will all be rows. |
1917 | | /// `-1.0` is the sweetspot for mostly preserving a 16:9 ratio. |
1918 | 0 | fn determine_client_spatial_attributes( |
1919 | 0 | index: i32, |
1920 | 0 | number_of_consoles: i32, |
1921 | 0 | workspace_area: &workspace::WorkspaceArea, |
1922 | 0 | aspect_ratio_adjustment: f64, |
1923 | 0 | ) -> (i32, i32, i32, i32) { |
1924 | 0 | let aspect_ratio = workspace_aspect_ratio(workspace_area); |
1925 | 0 | let (grid_columns, grid_rows) = |
1926 | 0 | grid_dimensions(number_of_consoles, aspect_ratio, aspect_ratio_adjustment); |
1927 | | |
1928 | 0 | let grid_column_index = index % grid_columns; |
1929 | 0 | let grid_row_index = index / grid_columns; |
1930 | | |
1931 | 0 | let is_last_row = grid_row_index == grid_rows - 1; |
1932 | 0 | let last_row_console_count = number_of_consoles % grid_columns; |
1933 | | |
1934 | 0 | let console_width = if is_last_row && last_row_console_count != 0 { |
1935 | 0 | (workspace_area.width / last_row_console_count) |
1936 | 0 | + if last_row_console_count > 1 { |
1937 | 0 | workspace_area.x_fixed_frame + workspace_area.x_size_frame |
1938 | | } else { |
1939 | 0 | 0 |
1940 | | } |
1941 | | } else { |
1942 | 0 | (workspace_area.width / grid_columns) |
1943 | 0 | + (workspace_area.x_fixed_frame + workspace_area.x_size_frame) |
1944 | | }; |
1945 | | |
1946 | 0 | let console_height = (workspace_area.height |
1947 | 0 | + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * grid_row_index) |
1948 | 0 | / grid_rows; |
1949 | | |
1950 | 0 | let x = grid_column_index * console_width |
1951 | 0 | - ((workspace_area.x_fixed_frame + workspace_area.x_size_frame) * (grid_column_index + 1)); |
1952 | 0 | let y = grid_row_index * console_height |
1953 | 0 | - ((workspace_area.y_fixed_frame + workspace_area.y_size_frame) * (grid_row_index - 1)); |
1954 | | |
1955 | 0 | return get_console_rect(x, y, console_width, console_height, workspace_area); |
1956 | 0 | } |
1957 | | |
1958 | | /// Transform the position and dimensions of a console window based |
1959 | | /// on the workspace area. |
1960 | | /// |
1961 | | /// To minimize empty space between windows, width and height must be adjusted |
1962 | | /// by the `fixed_frame` and `size_frame` values. |
1963 | | /// |
1964 | | /// # Arguments |
1965 | | /// |
1966 | | /// * `x` - The `x` coordinate of the window. |
1967 | | /// * `y` - The `y` coordinate of the window. |
1968 | | /// * `width` - The `width` in pixels of the window. |
1969 | | /// * `height` - The `height` in pixels of the window. |
1970 | | /// * `workspace_area` - The available workspace area on the primary monitor minus |
1971 | | /// the space occupied by the daemon console window. |
1972 | | /// |
1973 | | /// # Returns |
1974 | | /// |
1975 | | /// (`x`, `y`, `width`, `height`) |
1976 | | /// |
1977 | 0 | fn get_console_rect( |
1978 | 0 | x: i32, |
1979 | 0 | y: i32, |
1980 | 0 | width: i32, |
1981 | 0 | height: i32, |
1982 | 0 | workspace_area: &workspace::WorkspaceArea, |
1983 | 0 | ) -> (i32, i32, i32, i32) { |
1984 | 0 | return ( |
1985 | 0 | std::cmp::max( |
1986 | 0 | workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame), |
1987 | 0 | workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame) + x, |
1988 | 0 | ), |
1989 | 0 | workspace_area.y - (workspace_area.y_fixed_frame + workspace_area.y_size_frame) + y, |
1990 | 0 | std::cmp::min(workspace_area.width, width), |
1991 | 0 | height, |
1992 | 0 | ); |
1993 | 0 | } |
1994 | | |
1995 | | /// Spawns a background thread that ensures the z-order of all client |
1996 | | /// windows is in sync with the daemon window. |
1997 | | /// I.e. if the daemon window is focussed, all clients should be moved to the foreground. |
1998 | | /// |
1999 | | /// # Arguments |
2000 | | /// |
2001 | | /// * `windows_api` - Arc-wrapped Windows API implementation for thread-safe access |
2002 | | /// * `clients` - A thread safe mapping from the number |
2003 | | /// a client console window was launched at |
2004 | | /// in relation to the other client windows |
2005 | | /// and the clients console window handle. |
2006 | | /// The mapping must be thread safe to allow |
2007 | | /// it to be modified by the main thread |
2008 | | /// while we periodically read from it in the |
2009 | | /// background thread. |
2010 | 0 | fn ensure_client_z_order_in_sync_with_daemon<W: WindowsApi + Send + Sync + 'static>( |
2011 | 0 | windows_api: Arc<W>, |
2012 | 0 | clients: Arc<Mutex<Clients>>, |
2013 | 0 | ) { |
2014 | 0 | tokio::spawn(async move { |
2015 | 0 | let daemon_handle = get_console_window_wrapper(windows_api.as_ref()); |
2016 | 0 | let mut previous_foreground_window = get_foreground_window_wrapper(windows_api.as_ref()); |
2017 | | loop { |
2018 | 0 | tokio::time::sleep(Duration::from_millis(1)).await; |
2019 | 0 | let foreground_window = get_foreground_window_wrapper(windows_api.as_ref()); |
2020 | 0 | if previous_foreground_window == foreground_window { |
2021 | 0 | continue; |
2022 | 0 | } |
2023 | 0 | if foreground_window == daemon_handle |
2024 | 0 | && !clients.lock().unwrap().iter().any(|client| { |
2025 | 0 | return client.window_handle == previous_foreground_window.hwdn |
2026 | 0 | || client.window_handle == daemon_handle.hwdn; |
2027 | 0 | }) |
2028 | 0 | { |
2029 | 0 | defer_windows( |
2030 | 0 | windows_api.as_ref(), |
2031 | 0 | &clients.lock().unwrap(), |
2032 | 0 | &daemon_handle.hwdn, |
2033 | 0 | ); |
2034 | 0 | } |
2035 | 0 | previous_foreground_window = foreground_window; |
2036 | | } |
2037 | | }); |
2038 | 0 | } |
2039 | | |
2040 | | /// Move all given windows to the foreground. |
2041 | | /// |
2042 | | /// Restores minimized windows. |
2043 | | /// If a window handle no longer points to a valid window, it is skipped. |
2044 | | /// The daemon window is deferred last and receives focus. |
2045 | | /// |
2046 | | /// # Arguments |
2047 | | /// |
2048 | | /// * `windows_api` - The Windows API implementation to use |
2049 | | /// * `clients` - A thread safe mapping from the number |
2050 | | /// a client console window was launched at |
2051 | | /// in relation to the other client windows |
2052 | | /// and the clients console window handle. |
2053 | | /// * `daemon_handle` - Handle to the daemon console window. |
2054 | 0 | fn defer_windows<W: WindowsApi>(windows_api: &W, clients: &[Client], daemon_handle: &HWND) { |
2055 | 0 | for client in clients.iter() { |
2056 | 0 | restore_if_minimized(windows_api, client.window_handle, false); |
2057 | 0 | let _ = windows_api.bring_window_to_top(client.window_handle, false); |
2058 | 0 | } |
2059 | | // Raise the daemon last so it ends up on top and keeps keyboard focus. |
2060 | 0 | restore_if_minimized(windows_api, *daemon_handle, true); |
2061 | 0 | let _ = windows_api.bring_window_to_top(*daemon_handle, true); |
2062 | 0 | } |
2063 | | |
2064 | | /// Restore `window_handle` if its current placement reports minimized. |
2065 | | /// |
2066 | | /// Silently does nothing when the placement query fails or the window is |
2067 | | /// not minimized. Used by [`defer_windows`] so both client and daemon |
2068 | | /// windows are brought back from the taskbar before z-order updates. |
2069 | | /// |
2070 | | /// # Arguments |
2071 | | /// |
2072 | | /// * `windows_api` - Windows API implementation. |
2073 | | /// * `window_handle` - Handle to the window to potentially restore. |
2074 | | /// * `with_keyboard_focus` - Whether the restored window should be activated. |
2075 | | /// Pass `false` for client windows so unminimizing |
2076 | | /// them does not steal foreground from the daemon - |
2077 | | /// `SW_RESTORE` activates, which would let the |
2078 | | /// last-restored client win the foreground race and |
2079 | | /// block [`WindowsApi::bring_window_to_top`] from |
2080 | | /// refocusing the daemon. |
2081 | 0 | fn restore_if_minimized<W: WindowsApi>( |
2082 | 0 | windows_api: &W, |
2083 | 0 | window_handle: HWND, |
2084 | 0 | with_keyboard_focus: bool, |
2085 | 0 | ) { |
2086 | 0 | let placement = match windows_api.get_window_placement(window_handle) { |
2087 | 0 | Ok(placement) => placement, |
2088 | 0 | Err(_) => return, |
2089 | | }; |
2090 | 0 | if placement.showCmd == SW_SHOWMINIMIZED.0.try_into().unwrap() { |
2091 | 0 | let cmd = if with_keyboard_focus { |
2092 | 0 | SW_RESTORE |
2093 | | } else { |
2094 | 0 | SW_SHOWNOACTIVATE |
2095 | | }; |
2096 | 0 | let _ = windows_api.show_window(window_handle, cmd); |
2097 | 0 | } |
2098 | 0 | } |
2099 | | |
2100 | | /// The entrypoint for the `daemon` subcommand. |
2101 | | /// |
2102 | | /// Spawns 1 client process with its own window for each host |
2103 | | /// and 1 worker thread that handles communication with the client |
2104 | | /// over a named pipe. |
2105 | | /// Responsible for client window positioning and sizing. |
2106 | | /// Handles control mode. |
2107 | | /// Main thread reads input records from the console input buffer |
2108 | | /// and propagates them via the background threads to all clients |
2109 | | /// simultaneously. |
2110 | | /// |
2111 | | /// # Arguments |
2112 | | /// |
2113 | | /// * `windows_api` - The Windows API implementation to use |
2114 | | /// * `hosts` - List of hostnames for which to launch clients. |
2115 | | /// * `username` - Username used to connect to the hosts. |
2116 | | /// If none, each client will use the SSH config to determine |
2117 | | /// a suitable username for their respective host. |
2118 | | /// * `port` - Optional port used for all SSH connections. |
2119 | | /// * `config` - The `DaemonConfig`. |
2120 | | /// * `debug` - Enables debug logging |
2121 | 0 | pub async fn main<W: WindowsApi + Clone + 'static>( |
2122 | 0 | windows_api: &W, |
2123 | 0 | hosts: Vec<String>, |
2124 | 0 | username: Option<String>, |
2125 | 0 | port: Option<u16>, |
2126 | 0 | config: &DaemonConfig, |
2127 | 0 | clusters: &[Cluster], |
2128 | 0 | debug: bool, |
2129 | 0 | ) { |
2130 | 0 | let daemon: Daemon = Daemon { |
2131 | 0 | hosts: explode(&hosts.join(" ")).unwrap_or(hosts), |
2132 | 0 | username, |
2133 | 0 | port, |
2134 | 0 | config, |
2135 | 0 | clusters, |
2136 | 0 | control_mode_state: ControlModeState::Inactive, |
2137 | 0 | debug, |
2138 | 0 | }; |
2139 | 0 | daemon.launch(windows_api).await; |
2140 | 0 | debug!("Actually exiting"); |
2141 | 0 | } |
2142 | | |
2143 | | #[cfg(test)] |
2144 | | #[path = "../tests/daemon/test_mod.rs"] |
2145 | | mod test_mod; |